config.py
Rama main · réplica de lectura del repositorio original.
# SPDX-License-Identifier: BUSL-1.1
# Copyright (c) 2024-2026 Ali Sadeghi Aghili
"""Runtime configuration — all values read from environment / .env.
Never hardcode credentials here.
Copy .env.example → .env and fill in real values.
Usage::
import config as cfg
print(cfg.settings.openai_model)
Testing::
Use ``override_settings()`` to safely replace the singleton in tests.
Because every consumer accesses ``cfg.settings`` at call-time (not at
import-time), the patch is visible to ALL modules immediately.
with override_settings(max_rows_returned=5):
... # every cfg.settings access sees the patched value
Three layers, not two
----------------------
This codebase separates three kinds of thing that are easy to lump
together into "configuration":
1. **Engine** — the code itself (``api/``, ``llm/``, ``retrieval/``,
``security/``, ...). No domain names, no tuning literals: it must work
unmodified against any warehouse.
2. **Domain data** — what a *particular* warehouse *is*: its tables,
columns, business rules, aliases, few-shot examples
(``project_config/*.yaml``, loaded by :mod:`knowledge.config_loader`
and :mod:`schema_data.registry`). Swapping warehouses means swapping
this directory, never editing engine code.
3. **Tuning** — how aggressively the engine retrieves, matches, caches,
and retries against *this* warehouse and *this* hardware. This module.
A module-level numeric constant elsewhere in the source is not
automatically "tuning" just because layer 3 exists. Sorting a candidate
constant into one of three buckets:
* **Tuning** — a knob an operator might legitimately want different for
their warehouse or their hardware (a bigger cache, a longer timeout, a
looser regression tolerance on slower CI hardware, ...). Becomes a
``Settings`` field: ``field(default_factory=lambda: os.getenv(...))``,
documented, read through ``cfg.settings`` at call time so
``override_settings()`` reaches it in tests. See
``eval_max_accuracy_drop_pct`` / ``eval_max_latency_p95_increase_pct`` /
``eval_max_guard_rejection_increase`` below for an example promoted out
of ``eval/baseline.py`` under exactly this reasoning: how much
latency/accuracy regression is tolerable is a property of *this*
deployment's hardware and traffic, not a fixed technical fact.
* **Invariant** — a value that is part of the design's *correctness* and
must not be tuned, even though it happens to be a number. Stays a
source constant, with a docstring saying why it is deliberately not
configurable. The clearest example is
:data:`security.auth.MIN_KEY_LENGTH` (32): lowering it weakens a
security property (structural entropy enforced once, at key-issue
time), so exposing it as an env-overridable knob would let a deployment
quietly weaken its own auth by setting one variable. Also in this
bucket: :data:`eval.determinism.MIN_REPEATS` (a statistical floor —
fewer than 2 repeats cannot measure determinism at all, it is not a
"less thorough" setting) and :data:`eval.fingerprint.DEFAULT_FLOAT_PRECISION`
(a golden set's ``expected_fingerprint`` values are hashed at a fixed
precision; making this env-tunable would silently desynchronise a
deployment's environment from its own golden set's pinned hashes,
turning "regression" into "someone's shell profile" with no error at
either end).
* **Implementation detail** — a value nobody outside the module cares
about, or one whose only meaningful comparison is against itself /
against a value already tunable elsewhere. Stays put, no docstring
justification required beyond the ordinary one.
:data:`prompt_engine.static_prefix._CHARS_PER_TOKEN` lives here: it is
a rough characters-per-token heuristic (no real tokenizer dependency),
used only in comparisons against itself and against
:attr:`prompt_retrieval_token_budget` below — a deployment whose text
tokenizes at a different real ratio (e.g. Persian vs English) already
has the one knob it needs in :attr:`prompt_retrieval_token_budget`;
giving the ratio its own env var would add a second dial over the same
effective threshold rather than a genuinely independent one.
:data:`database.schema_inspector._MAX_SAMPLE_LEN` is the same kind of
thing one level further out: a display-truncation length inside the
interactive, untested, coverage-excluded setup wizard
(``setup_project.py``), never read by the running engine at all.
This rule is enforced, not just documented: ``tests/test_tuning_layer.py``
walks first-party source for a *new* bare module-level numeric constant
outside its allowlist and fails the build, the same way
``tests/test_persian_normalization.py`` already does for a second
translation table built the same way ``core.persian`` builds its own.
See that new test's module docstring for exactly which existing constants
are allowlisted and why — several are legitimately tuning-shaped but
already have a more appropriate, narrower configuration surface than a
process-wide environment variable (e.g. the eval CLI's own
``--determinism-repeats`` flag; see :data:`eval.determinism.DEFAULT_REPEATS`)
and were deliberately left alone rather than duplicated into ``Settings``.
"""
from __future__ import annotations
import os
import sys
from contextlib import contextmanager
from dataclasses import dataclass, field
from functools import lru_cache
from typing import Any, Generator
# ---------------------------------------------------------------------------
# .env loading — must happen before Settings() is ever constructed below,
# so that os.getenv() calls in the field default_factories see values from
# .env. This is the single, earliest point every entry point (app.py,
# api/server.py, tests) goes through, since they all ``import config``.
#
# python-dotenv is a required dependency (see requirements.txt), but we
# degrade gracefully instead of crashing the whole application if it is
# ever missing from an environment (e.g. a minimal container image that
# only sets real environment variables and has no .env file at all).
# ---------------------------------------------------------------------------
#: The ``.env`` file python-dotenv loaded, and what it could not use in it
#: (:func:`core.dotenv_check.scan_dotenv`). Empty path = no ``.env`` file.
#: Set once, below; :meth:`Settings.validate` refuses to start on any problem
#: because python-dotenv itself only prints a one-line warning to stderr.
_dotenv_path: str = ""
_dotenv_problems: list[DotenvProblem] = []
#: ``PYTHON_DOTENV_DISABLED`` was set, so no ``.env`` was searched for or loaded.
_dotenv_disabled: bool = False
#: python-dotenv itself could not be imported, so no ``.env`` was read at
#: all. Set once, below.
_dotenv_missing: bool = False
#: python-dotenv is installed but :mod:`core.dotenv_check` could not be
#: imported: ``.env`` is still loaded, only not scanned or explained.
_dotenv_scan_unavailable: bool = False
def _dotenv_search_start() -> str:
"""The folder python-dotenv's ``find_dotenv()`` starts searching from.
``find_dotenv()`` starts at the folder of the file that called it, and
walks up from there. It starts at the current working directory instead
when it is called interactively (``sys.ps1`` / ``sys.ps2`` exist, or
``__main__`` has no ``__file__``), under a debugger or tracer
(``sys.gettrace()`` is not ``None``), or from a frozen executable
(``sys.frozen``). These are the same conditions, and this must be called
from this module's top level at the moment ``find_dotenv()`` is, so that
"the file that called it" is this file.
Returns
-------
str
The folder the search starts from; the message that names the folders
searched for a ``.env`` reports it.
Examples
--------
>>> import os
>>> _dotenv_search_start() in (os.getcwd(), os.path.dirname(os.path.abspath(__file__)))
True
"""
interactive = hasattr(sys, "ps1") or hasattr(sys, "ps2")
if not interactive:
try:
main = __import__("__main__", None, None, fromlist=["__file__"])
except ModuleNotFoundError: # pragma: no cover - python-dotenv treats it as not interactive
main = None
interactive = main is not None and not hasattr(main, "__file__")
if interactive or sys.gettrace() is not None or getattr(sys, "frozen", False):
return os.getcwd()
return os.path.dirname(os.path.abspath(__file__))
#: The folder ``find_dotenv()`` actually started from (see
#: :func:`_dotenv_search_start`); recorded at import, next to the search.
_dotenv_search_dir: str = os.path.dirname(os.path.abspath(__file__))
try:
from dotenv import find_dotenv, load_dotenv
except ImportError: # pragma: no cover - exercised only when dependency missing
_dotenv_missing = True
else:
try:
from core.dotenv_check import DotenvProblem, scan_dotenv
except ImportError: # pragma: no cover - python-dotenv is there; the scan is not
_dotenv_scan_unavailable = True
# find_dotenv() here, not inside load_dotenv(): it searches from the
# calling file's directory, and the scan must read the same file.
# PYTHON_DOTENV_DISABLED turns loading off, and with it the check.
_dotenv_disabled = os.environ.get("PYTHON_DOTENV_DISABLED", "").casefold() in (
"1", "true", "t", "yes", "y",
)
if not _dotenv_disabled:
_dotenv_search_dir = _dotenv_search_start()
_dotenv_path = find_dotenv()
if not _dotenv_scan_unavailable:
_dotenv_problems = scan_dotenv(_dotenv_path)
load_dotenv(_dotenv_path or None)
def _dotenv_missing_sentence() -> str:
"""The one sentence for an environment without python-dotenv."""
return (
f"python-dotenv is not installed in this Python environment ({sys.executable}), "
"so .env was not read; install the requirements into it: "
"pip install -r requirements.lock"
)
def _dotenv_unscanned_sentence(variable: str) -> str:
"""The one sentence when python-dotenv loaded ``.env`` but it cannot be inspected."""
where = f"{_dotenv_path} was loaded" if _dotenv_path else "no .env file was found"
return (
f"{where}, but core.dotenv_check could not be imported, so it was not "
f"inspected; check that the .env python-dotenv loads assigns {variable}"
)
def explain_unset_variable(variable: str) -> list[str]:
"""Why *variable*, expected from ``.env``, is unset or empty in this process.
For a message that otherwise only says "not set or is empty": which
``.env`` was loaded (or that none was found, and the folder the search
started from), what python-dotenv could not use in it, and whether the
file assigns the variable at all. See :func:`core.dotenv_check.explain_unset`.
This is only ever an addition to an error that is already being raised,
so it never raises: any failure while building it yields ``[]``.
Parameters
----------
variable:
The environment variable that was needed.
Returns
-------
list[str]
Sentences naming files, lines and variables, never a value. When
python-dotenv is not installed in this environment, the single
sentence saying so (it names the interpreter), because then ``.env``
was never read. When python-dotenv loaded ``.env`` but
:mod:`core.dotenv_check` cannot be imported, one sentence saying the
file was not inspected. ``[]`` if the explanation itself failed.
Examples
--------
>>> import config
>>> lines = config.explain_unset_variable("NO_SUCH_VARIABLE_FOR_THE_DOCTEST")
>>> isinstance(lines, list)
True
"""
try:
if _dotenv_missing:
return [_dotenv_missing_sentence()]
if _dotenv_scan_unavailable:
return [_dotenv_unscanned_sentence(variable)]
try:
from core.dotenv_check import explain_unset
except ImportError:
return [_dotenv_unscanned_sentence(variable)]
return explain_unset(
variable,
_dotenv_path,
_dotenv_problems,
os.environ,
# The folder find_dotenv() really searched from: not always this file's.
_dotenv_search_dir,
disabled=_dotenv_disabled,
)
except Exception as exc: # noqa: BLE001 - never mask the error being explained
import logging
# The exception type only: its text could quote a .env line.
logging.getLogger(__name__).debug(
"could not explain why %s is unset (%s)", variable, type(exc).__name__
)
return []
#: Origins the bundled ``web/`` UI is documented to be served from, and the
#: default for :attr:`Settings.cors_allowed_origins`.
#:
#: This used to default to empty -- the most restrictive choice, and the
#: wrong one, because the layout this project *documents* is the API on one
#: port and the static UI on another. A browser on 8080 calling 8000 is
#: cross-origin, so the out-of-the-box experience was a preflight answered
#: "400 Disallowed CORS origin", which every browser reports to the page as
#: the uninformative "Failed to fetch". The UI showed API, LLM and DB all
#: down while the CLI, which is not a browser and never sends an Origin,
#: worked perfectly against the same server.
#:
#: Loopback only, and that is what makes it safe to default: a page cannot
#: choose the ``Origin`` the browser sends, so allowing these helps only a
#: page genuinely served from this machine's own 8080 -- and every route it
#: could then reach still requires an API key. Anything else, including any
#: non-loopback host, still has to be named explicitly.
DEFAULT_CORS_ALLOWED_ORIGINS: tuple[str, ...] = (
"http://localhost:8080",
"http://127.0.0.1:8080",
)
def _parse_port(env_var: str, default: str) -> int:
"""Parse *env_var* (falling back to *default*) as a TCP port number,
raising a message that names the variable and the bad value instead
of letting a bare ``int(...)`` ``ValueError`` -- "invalid literal for
int() with base 10: '...'", which names neither -- propagate out of a
``default_factory`` and crash ``import config`` with nothing an
operator could act on without already knowing this module's
internals."""
raw = os.getenv(env_var, default)
try:
port = int(raw)
except ValueError:
raise ValueError(f"{env_var} must be an integer (got {raw!r})") from None
if not (1 <= port <= 65535):
raise ValueError(f"{env_var} must be between 1 and 65535 (got {port})")
return port
def _parse_optional_percent(env_var: str) -> float | None:
"""Parse *env_var* as an optional percentage in ``[0, 100]``.
An unset or blank variable means "not configured" and yields ``None``
(the feature stays off). A set variable that is not a number, or lies
outside ``0``-``100``, raises a message naming the variable and the bad
value rather than a bare ``float()`` error, for the same reason as
:func:`_parse_port`.
Args:
env_var: Name of the environment variable to read.
Returns:
The percentage as a float, or ``None`` when unset or blank.
Raises:
ValueError: If the value is not a finite number between 0 and 100.
Examples:
>>> import os
>>> os.environ.pop("_DEMO_PCT", None) is None
True
>>> _parse_optional_percent("_DEMO_PCT") is None
True
>>> os.environ["_DEMO_PCT"] = " 92.5 "
>>> _parse_optional_percent("_DEMO_PCT")
92.5
>>> os.environ["_DEMO_PCT"] = "120"
>>> _parse_optional_percent("_DEMO_PCT")
Traceback (most recent call last):
...
ValueError: _DEMO_PCT must be a percentage between 0 and 100 (got '120')
>>> del os.environ["_DEMO_PCT"]
"""
raw = os.getenv(env_var, "").strip()
if not raw:
return None
try:
value = float(raw)
except ValueError:
value = float("nan")
if not (0.0 <= value <= 100.0): # also rejects nan
raise ValueError(
f"{env_var} must be a percentage between 0 and 100 (got {raw!r})"
)
return value
def _has_placeholder_login_host(url: str) -> bool:
"""Whether *url* still names ``username@server``, the factory default.
The literal text is matched, and so is the parsed login and host: once
``DB_PASSWORD`` is set on the URL it reads ``username:<password>@server``,
which the literal text no longer matches.
Examples
--------
>>> _has_placeholder_login_host("mssql+pyodbc://username@server:1433/db")
True
>>> _has_placeholder_login_host("mssql+pyodbc://username:pw@server/db")
True
>>> _has_placeholder_login_host("mssql+pyodbc://nlq:pw@db1/db")
False
"""
if "username@server" in url.lower():
return True
from sqlalchemy.engine import make_url
try:
parsed = make_url(url)
except Exception: # noqa: BLE001 - an unparsable URL has no login to compare
return False
return (parsed.username or "").lower() == "username" and (parsed.host or "").lower() == "server"
def _unset_variable_error(message: str, variable: str) -> ValueError:
"""An :class:`~database.datasources.UnsetVariableError` (imported late: it imports this module)."""
from database.datasources import UnsetVariableError
return UnsetVariableError(message, variable)
def _check_warehouse_url(label: str, url: str, dialect: str, placeholders: set[str]) -> None:
"""Refuse an unset, placeholder or wrong-dialect warehouse connection string.
Two independent placeholder checks apply: an exact match against
*placeholders* (single unfilled tokens such as ``"change_me"``) and a
substring check for ``"username@server"``, the literal host baked into
:attr:`Settings.db_connection_url`'s factory default. The default is a
*full connection string*, not a bare token, so it never equals any
entry in *placeholders* and used to pass validation silently whenever
a user copied ``.env.example`` and forgot to fill in the variable.
The dialect check catches the "set SQL_DIALECT but forgot to update the
connection string" misconfiguration -- a mismatch means every query
would fail at execution (or be silently misinterpreted). It parses the
URL string only (``sqlalchemy.engine.make_url`` needs no driver import
and opens no connection); an exotic backend with no sqlglot-dialect
mapping is not an error on its own and is skipped.
Parameters
----------
label:
Environment variable the URL came from, used in the message.
url:
The connection string.
dialect:
The sqlglot dialect this connection must speak.
placeholders:
Unfilled-token values to refuse.
Raises
------
ValueError
Naming *label* and what is wrong with it.
database.datasources.UnsetVariableError
(A :class:`ValueError`.) *label* is an environment variable name and
the variable is unset or empty -- the URL is empty, or it is the
factory-default placeholder because ``DB_CONNECTION_URL`` is not in
the environment. The message is the same; the exception carries the
variable so :meth:`Settings.validate` can say why it is missing.
"""
if not url or url in placeholders:
message = f"{label} is not configured"
if not url and label.isidentifier():
raise _unset_variable_error(message, label)
raise ValueError(message)
if _has_placeholder_login_host(url):
message = (
f"{label} still has the factory-default placeholder "
"host (username@server) — set a real connection string in .env"
)
if label.isidentifier() and not os.environ.get(label):
raise _unset_variable_error(message, label)
raise ValueError(message)
from sqlalchemy.engine import make_url
from security.dialects import sqlglot_dialect_for_backend
try:
backend_name = make_url(url).get_backend_name()
expected_dialect = sqlglot_dialect_for_backend(backend_name)
except Exception: # noqa: BLE001 - an unrecognised backend is a
# different, unrelated concern -- not something this
# SQL_DIALECT/connection-string consistency check can judge.
expected_dialect = None
if expected_dialect is not None and expected_dialect != dialect:
raise ValueError(
f"SQL_DIALECT={dialect!r} does not match "
f"{label}'s backend ({backend_name!r}, which "
f"this deployment targets as {expected_dialect!r}) -- "
"every query would fail at execution against the wrong "
f"dialect. Set SQL_DIALECT to match {label}, or "
f"fix {label} to point at the intended database."
)
@dataclass(frozen=True, slots=True)
class Settings:
"""Immutable runtime settings resolved from environment variables."""
# ── LLM provider (OpenAI-compatible endpoint only) ──────────────────
openai_base_url: str = field(
default_factory=lambda: os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1")
)
"""Base URL of the single, trivial-case OpenAI-compatible endpoint
(:class:`~llm.endpoints.EndpointConfig` named ``"default"``) — a local
``gpt-oss``/vLLM/llama.cpp server, or OpenAI's own hosted API. See
``LLM_ENDPOINTS``/``LLM_ROUTES`` (:attr:`llm_endpoints_json` /
:attr:`llm_routes_json`) for routing across more than one endpoint."""
openai_model: str = field(
default_factory=lambda: os.getenv("OPENAI_MODEL", "gpt-4o-mini")
)
"""Model tag sent to :attr:`openai_base_url`."""
openai_api_key: str = field(
default_factory=lambda: os.getenv("OPENAI_API_KEY", "")
)
"""Bearer token for :attr:`openai_base_url`. Empty is valid — many
self-hosted OpenAI-compatible servers don't check it."""
# ── HTTP server binding (``python -m api`` launcher) ────────────────
# The API port has always been settable on the `uvicorn` command line
# (`--port`) but never in `.env` -- an operator who only ever edits
# `.env` (the documented workflow for every other setting on this
# page) had no way to change it there, and started the server on the
# default port while the rest of their `.env` assumed a different one.
# These two exist so `.env` is a complete description of where the
# server binds, and `python -m api` (this module's own `__main__.py`)
# is a launcher that actually reads them -- the plain `uvicorn ...`
# command in the runbook/README keeps working unmodified either way,
# since uvicorn itself never reads these variables.
api_host: str = field(default_factory=lambda: os.getenv("API_HOST", "127.0.0.1"))
"""Interface ``python -m api`` binds to. Defaults to the loopback-only
``127.0.0.1`` -- a freshly-cloned checkout should not be reachable
from the network until an operator deliberately widens it. The
documented deployment command binds ``0.0.0.0`` (every interface, see
``docs/deployment-runbook.md`` step 4) explicitly, on the command
line, precisely because that choice should be visible at the call
site rather than silently inherited from a default an operator never
looked at."""
api_port: int = field(default_factory=lambda: _parse_port("API_PORT", "8000"))
"""Port ``python -m api`` binds to. ``8000`` matches every other
default in this codebase that assumes the API is reachable at
``http://localhost:8000`` (``web/js/config.js``'s ``DEFAULT_API_PORT``,
``DEFAULT_CORS_ALLOWED_ORIGINS`` above's counterpart on the UI side,
the runbook, the README). Changing it here changes what ``python -m
api`` binds to; a plain ``uvicorn ...`` invocation is unaffected --
its port comes only from its own ``--port`` flag.
A non-integer or out-of-range (outside 1-65535) ``API_PORT`` fails
``import config`` immediately with a message naming ``API_PORT`` and
the offending value (see :func:`_parse_port`) rather than a bare
``int()`` traceback that names neither."""
log_level: str = field(default_factory=lambda: os.getenv("LOG_LEVEL", "INFO"))
"""Level (``"DEBUG"``/``"INFO"``/``"WARNING"``/...) the application's
own loggers emit at, applied to the ROOT logger by
:func:`core.logging_setup.configure_stdlib_logging` -- called once,
early, by both ``api/server.py``'s ``lifespan`` and ``python -m api``
(``api/__main__.py``) -- but only when nothing has configured logging
yet (root has no handler). Exists because neither documented way of
starting the HTTP API otherwise makes an ``INFO`` line -- the startup
provenance banner, the CORS-allowlist line -- reach anywhere at all:
uvicorn's own default logging config never touches the root logger,
which Python itself starts at ``WARNING`` with no handler. See
``core.logging_setup``'s module docstring for the full mechanism."""
db_connection_url: str = field(
default_factory=lambda: os.getenv(
"DB_CONNECTION_URL",
"mssql+pyodbc://username@server:1433/Auction_DM"
"?driver=ODBC+Driver+17+for+SQL+Server&trusted_connection=yes",
)
)
"""The single warehouse connection, used only when there is no
``project_config/datasources.yaml``. Any password written inside it must
be percent-encoded (``@`` as ``%40``); to avoid that, leave the password
out and give it raw in :attr:`db_password`."""
db_password: str = field(
default_factory=lambda: os.getenv("DB_PASSWORD", ""),
repr=False,
)
"""The raw password for :attr:`db_connection_url` (``DB_PASSWORD``), with
no URL encoding. Empty (the default) leaves the URL exactly as written.
When set, the URL must carry a login but no password: it is set on the
parsed URL by :func:`database.datasources.apply_db_password`, and a
password present in both places is refused at start-up. Ignored when
``datasources.yaml`` exists (each source there names its own
``password_env``). Excluded from ``repr`` so it never reaches a log."""
db_pool_pre_ping: bool = field(
default_factory=lambda: os.getenv("DB_POOL_PRE_PING", "true").lower()
in ("1", "true", "yes")
)
"""Whether :func:`database.connection.get_engine` (and, for a
non-SQLite backend, :func:`appdb.engine.build_engine`) installs the
idle-aware pre-ping described under :attr:`db_pool_ping_idle_seconds`
below (Finding 1, 2026 warehouse-load audit, revised by the
idle-aware-ping follow-up). Defaults to ``True``. ``False`` means
exactly what it always has: never ping a pooled connection on
checkout, full stop — :attr:`db_pool_ping_idle_seconds` has no effect
at all when this is off.
What it trades off
-------------------
**On (default).** A connection that has sat idle in the pool for at
least :attr:`db_pool_ping_idle_seconds` is probed with ``SELECT 1``
before being handed to the caller; one that has been used more
recently than that is handed over unprobed. A connection that has
gone stale (the SQL Server side closed it, a firewall/load-balancer
idle-timed it out) is discovered and replaced before the caller's
real query ever sees it, instead of surfacing as a query failure —
real safety, which is why the default stays on. Unlike the
unconditional ``pool_pre_ping=True`` this setting used to map to
one-for-one, the probe cost now scales with how often a connection
goes idle for that long, not with every single checkout — see
:attr:`db_pool_ping_idle_seconds` for the threshold and the
``DB_POOL_PING_IDLE_SECONDS=0`` escape hatch back to today's
ping-every-checkout behaviour.
**Off.** No probe on checkout, ever, regardless of idle time. A
connection that went stale while idle in the pool is only discovered
when a real query is sent through it, which then fails once and is
transparently retried on a fresh connection (SQLAlchemy's own pool
invalidate-and-retry behaviour on a disconnect-class error) — one
query pays a one-time retry cost instead of any checkout paying a
probe cost.
**Why turning it off is a reasonable choice here, not just a
trade-off**: with :attr:`db_pool_recycle_seconds` (below) set well
under whatever idle timeout the network path (SQL Server itself, a
firewall, a load balancer) actually enforces, a connection is
proactively recycled before it would ever go stale from sitting idle
— the failure this setting exists to catch becomes rare rather than
routine. Turn this off only after confirming (with the DBA) what that
idle timeout actually is and setting ``DB_POOL_RECYCLE_SECONDS``
comfortably below it; turning it off with no matching recycle margin
trades a quiet steady cost for occasional, noisier
first-query-after-idle failures.
Read once by :func:`database.connection.get_engine` /
:func:`appdb.engine.build_engine` when the engine is constructed
(like every other pool-shape setting there); changing it at runtime
has no effect until the next :func:`~database.connection.dispose_engine`
/ :func:`~appdb.engine.dispose_app_engine`. Also consulted by
:func:`api.health._ping_db` — see that function's docstring for why it
changes how many round trips one health probe costs, not just whether
checkout is probed."""
db_pool_ping_idle_seconds: int = field(
default_factory=lambda: int(os.getenv("DB_POOL_PING_IDLE_SECONDS", "60"))
)
"""How long (in seconds) a pooled connection must have sat unused
before :attr:`db_pool_pre_ping` (when on) actually probes it on
checkout, rather than handing it straight to the caller. Default
``60``.
This is the fix for the specific complaint that started the
idle-aware-ping follow-up to Finding 1: with plain
``pool_pre_ping=True``, *every* checkout of a pooled connection sends
``SELECT 1`` first, even one microsecond after the previous caller
checked the very same connection back in — a DBA sees this as a
constant, checkout-rate-scaled stream of ``SELECT 1`` regardless of
how few real questions are actually being asked. Tracking how long a
connection has actually sat idle (via SQLAlchemy's documented
connect/checkin/checkout pool events, see
:func:`database.pool_ping.install_idle_aware_ping`) and skipping the
probe when it has not been idle that long turns the cost back into
"roughly one probe per burst of activity" instead of "one probe per
query", while keeping exactly the same safety net for a connection
that really has gone stale from sitting unused.
``0`` means "ping literally every checkout" — the old, simpler
guarantee, for an operator who wants that back rather than the
idle-aware trade-off, and its failure handling (invalidate the whole
pool, not just one connection) now matches plain ``pool_pre_ping=True``
exactly. It is not a byte-for-byte reproduction of old
``pool_pre_ping=True`` in one narrow respect: SQLAlchemy's own
pre-ping never pings the very first checkout of a brand-new
connection (a per-connection "fresh" flag, independent of idle time);
``0`` here pings even that one, since it honours "every checkout"
literally. See :func:`database.pool_ping.install_idle_aware_ping`'s
docstring for the full detail — the difference is observable only
once per physical connection, on its first use, never again after.
A negative value is not a supported input and is treated as ``0``
would only coincidentally be handled the same way the idle comparison
happens to read it (every checkout looks "idle enough"); use ``0``
explicitly for that behaviour rather than relying on a negative
number.
Has no effect at all when :attr:`db_pool_pre_ping` is ``False`` — that
setting means "never ping", and this one only ever narrows *when*
that ping fires, never widens it back on. Read once when the engine is
built, exactly like :attr:`db_pool_pre_ping` itself."""
db_pool_recycle_seconds: int = field(
default_factory=lambda: int(os.getenv("DB_POOL_RECYCLE_SECONDS", "3600"))
)
"""Seconds after which :func:`database.connection.get_engine` recycles
a pooled connection (SQLAlchemy's ``pool_recycle``), previously a bare
``3600`` literal in that function. See :attr:`db_pool_pre_ping` above
for why lowering this (below whatever idle timeout the network path
to the warehouse actually enforces) is the documented alternative to
leaving ``pool_pre_ping`` on."""
sql_dialect: str = field(
default_factory=lambda: os.getenv("SQL_DIALECT", "tsql")
)
"""The single SQL dialect this deployment targets, as a
`sqlglot <https://sqlglot.com/>`_ dialect key -- one of ``"tsql"``
(default), ``"postgres"``, ``"mysql"``, ``"sqlite"`` (see
:data:`security.dialects.DIALECT_PROFILES`). Resolved once from config
at start-up, the same way :class:`~llm.router.LLMRouter` resolves an
endpoint -- this is portability (one dialect per deployment), not
runtime routing across several live databases.
SQL generation itself is unaffected by this setting: the model always
generates ``tsql`` regardless (see ``<PROJECT_CONFIG_DIR>/system_prompt.md``
and the multi-dialect phase report for why the static prompt prefix must
stay byte-identical across every deployment). When this resolves to
anything other than ``"tsql"``, ``llm.sql_agent.SQLAgent`` transpiles
the guard-approved ``tsql`` SQL to this dialect with sqlglot and
re-validates the **transpiled** text with
:func:`~security.sql_guard.validate_sql` pinned to this dialect before
executing it -- never executing anything the guard has not approved in
the dialect it will actually run in. When this is ``"tsql"`` (the
default), that transpile-and-revalidate step is skipped entirely and
the guard-approved SQL is executed exactly as produced, unchanged from
this deployment's original, single-dialect behaviour.
Validated at start-up by :meth:`validate` via
:func:`security.dialects.require_dialect_supported`, which fails
closed for an unknown dialect or one with no system-catalogue
blocklist configured -- see that function's docstring."""
db_application_name: str = field(
default_factory=lambda: os.getenv("DB_APPLICATION_NAME", "local-sql-agent")
)
"""Client application name identified to the warehouse (Finding 5,
2026 warehouse-load audit) — what a DBA sees as ``program_name`` in
``sys.dm_exec_sessions``/``sys.dm_exec_requests`` when attributing a
trace or a blocking session to this application instead of to "some
unlabelled connection". Only applied when :attr:`db_connection_url`
is an ``mssql+pyodbc`` URL that does not already set one (see
:func:`database.connection_identity.with_application_name`) — never
overrides a value an operator already configured, and is a no-op for
every other dialect (SQLite in tests, or a non-``pyodbc`` driver)."""
query_timeout_seconds: int = field(
default_factory=lambda: int(os.getenv("QUERY_TIMEOUT_SECONDS", "60"))
)
max_rows_returned: int = field(
default_factory=lambda: int(os.getenv("MAX_ROWS_RETURNED", "1000"))
)
default_top_n: int = field(
default_factory=lambda: int(
os.getenv("DEFAULT_TOP_N", os.getenv("MAX_ROWS_RETURNED", "1000"))
)
)
"""Row cap injected by :func:`security.sql_guard.ensure_top` into
generated SQL that has no ``TOP``/row-limit clause of its own.
Defaults to whatever ``MAX_ROWS_RETURNED`` resolves to (the same
number the client-side ``fetchmany`` cap already uses), so the two
caps agree unless ``DEFAULT_TOP_N`` is set independently."""
log_dir: str = field(
default_factory=lambda: os.getenv("LOG_DIR", "logs")
)
export_dir: str = field(
default_factory=lambda: os.getenv("EXPORT_DIR", "exports")
)
project_config_dir: str = field(
default_factory=lambda: os.getenv("PROJECT_CONFIG_DIR", "project_config")
)
"""Directory :mod:`knowledge.config_loader` and :mod:`schema_data.registry`
read their YAML files from (``aliases.yaml``, ``entities.yaml``,
``business_rules.yaml``, ``examples.yaml``, ``metrics.yaml``,
``schema.yaml``). Defaults to ``project_config`` — the git-ignored
directory holding this deployment's real domain data and warehouse
schema. Point this at ``project_config.example`` to run against the
committed, sample-data template instead (a fresh clone with no
``project_config/`` at all, or CI).
Deliberately **not** an automatic fallback: when this resolves to
``project_config`` (the default) and that directory or one of its
files is missing, loading still raises ``ConfigNotFoundError`` exactly
as before — silently running on sample aliases/business rules against
a real warehouse would produce confidently wrong SQL, which is worse
than refusing to start. The example directory is only ever reached by
explicitly setting this variable."""
staging_patterns: str = field(
default_factory=lambda: os.getenv(
"STAGING_PATTERNS",
"*_Stg,*stg*,*staging*,table:*_Inserts,table:*_Updates,table:*_Deletes",
)
)
"""Which ``schema.yaml`` tables ``scripts/verify_deployment.py``'s
``No staging or change-capture tables`` check reports as probably not
meant for analysts. Comma-separated ``fnmatch`` patterns, matched
case-insensitively. A bare pattern is matched against the table's
schema name (``*_Stg`` matches ``Sales_Stg``); a ``table:`` prefix
matches the table's own name instead (``table:*_Inserts`` matches
``Invoice_Inserts``, whatever its schema). The default covers staging
schemas and the ``_Inserts`` / ``_Updates`` / ``_Deletes`` tables a
change-capture process writes. An empty value turns the check off. The
check only advises (it never fails), and reads ``schema.yaml``, not the
database."""
exclude_schemas: str = field(
default_factory=lambda: os.getenv("EXCLUDE_SCHEMAS", "")
)
"""Database schemas the setup tools never scan, for every data source:
comma-separated ``fnmatch`` patterns, matched case-insensitively against
the schema name (``*_Stg`` matches ``Sales_Stg`` and ``sales_stg``). Added
to the always-excluded system schemas
(:data:`database.schema_filter.SYSTEM_SCHEMAS`) and to a data source's own
``exclude_schemas:`` list in ``datasources.yaml``, so a deployment with a
single ``DB_CONNECTION_URL`` has the same control as one with several
sources. It decides which catalogue tables the inspector drafts, the
``sync_schema.py`` report lists and ``--add-tables`` adds; a table
``schema.yaml`` already lists is synced as before, and the preflight check
``No table in an excluded schema`` reports one that sits in such a schema.
Empty (the default) excludes nothing beyond the system schemas."""
exclude_tables: str = field(
default_factory=lambda: os.getenv("EXCLUDE_TABLES", "")
)
"""Database tables the setup tools never scan, for every data source:
comma-separated ``fnmatch`` patterns, matched case-insensitively against
``schema.table`` (``*.Order`` matches ``sales.Order`` and ``ref.order``;
``X_Stg.*_Delete`` matches ``x_stg.Trade_Delete``). A pattern with no ``.``
is matched against the table name alone, in any schema. Added to a data
source's own ``exclude_tables:`` list in ``datasources.yaml``, so a
deployment with a single ``DB_CONNECTION_URL`` has the same control as one
with several sources. Like ``EXCLUDE_SCHEMAS`` it decides which catalogue
tables the inspector drafts, the ``sync_schema.py`` report lists and
``--add-tables`` adds; a table ``schema.yaml`` already lists is synced as
before, and the preflight check ``No table in an excluded schema`` reports
one that matches. Empty (the default) excludes nothing."""
# ── query result cache ────────────────────────────────────────────────
cache_ttl_seconds: int = field(
default_factory=lambda: int(os.getenv("CACHE_TTL_SECONDS", "300"))
)
"""How long (seconds) a cached query result stays valid. 0 = disabled."""
cache_max_size: int = field(
default_factory=lambda: int(os.getenv("CACHE_MAX_SIZE", "256"))
)
"""Maximum number of distinct (question, mode) pairs to keep in memory."""
# ── /health probe cache (Finding 3, 2026 audit) ─────────────────────────
health_cache_ttl_seconds: int = field(
default_factory=lambda: int(os.getenv("HEALTH_CACHE_TTL_SECONDS", "15"))
)
"""How long (seconds) ``api/health.py::check_health()`` reuses a probe
result before running the (real, ~5s-timeout, connection-pool-using)
LLM-endpoint and database checks again.
``/health`` needs no credentials and ``api/middleware.py`` deliberately
keeps it exempt from rate limiting -- a liveness probe must never be
throttled away, or an orchestrator kills a healthy container -- which
before this field existed meant an unauthenticated caller could hold
open a connection from the same pool ``/query`` uses, with no limit on
how often. Caching (not rate-limiting) is the fix: see
``api/health.py``'s own "Result caching" section for the incident that
motivated it and the reasoning for why caching is the right lever here.
Long enough to collapse a burst or a tightly-polling load balancer
into a single real probe; short enough that a genuine outage (or
recovery) still surfaces well within a liveness-check interval.
``api/health.py`` re-asserts ``0 < value <= 30`` as a documented
invariant of the deployment default, not enforced here -- see that
module's own test coverage. Exposed as ``api.health.HEALTH_CACHE_TTL_SECONDS``
via that module's ``__getattr__`` (a live read of this field on every
access, never a value captured once at import time) so existing callers
reading it as a module-level constant keep working unchanged."""
# ── Admin panel: expensive-card result cache (Finding 2, 2026 audit) ────
admin_expensive_cache_ttl_seconds: int = field(
default_factory=lambda: int(os.getenv("ADMIN_EXPENSIVE_CACHE_TTL_SECONDS", "300"))
)
"""How long (seconds) the admin panel's warehouse-touching cards --
currently ``GET /admin/health/checks`` (every
:mod:`scripts.verify_deployment` check, run live) and
``GET /admin/schema-drift`` (a full catalogue reflection of every
table/column in every schema) -- reuse their last result before
running the underlying checks again. See :mod:`api.admin_result_cache`'s
module docstring for the incident this fixes: ``web/admin/main.js``'s
30-second auto-refresh used to re-run both of these on every tick with
an admin tab merely left open, and the deployment-checks card also ran
a rolled-back ``CREATE TABLE`` DDL attempt and a ``WAITFOR DELAY``
probe on every one of those ticks (see ``api.admin_routes.admin_health_checks``'s
``deep`` parameter — gating those two checks behind an explicit
opt-in is a separate fix, not this TTL).
``0`` disables caching entirely (every request runs the check live),
matching :attr:`cache_ttl_seconds`'s own "0 = disabled" convention.
Each card's own ``?refresh=1`` query parameter bypasses a still-valid
cached value for that one request without changing this setting or
affecting any other card's cache. A failed check (e.g. the warehouse
is genuinely unreachable) is never itself cached — see
:mod:`api.admin_result_cache`'s "a failed computation is never
cached" note — so a real outage is never masked behind a stale
"everything passed" result for the length of this TTL.
300s (5 minutes) is long enough that leaving the panel open all day
costs a handful of runs rather than thousands, and short enough that
an operator who deployed a fix and reopens the panel a few minutes
later sees it reflected without needing to know to press refresh."""
# ── JSONL log rotation ──────────────────────────────────────────────────
log_max_bytes: int = field(
default_factory=lambda: int(os.getenv("LOG_MAX_BYTES", str(50 * 1024 * 1024)))
)
"""Size cap (bytes) for a JSONL log file (``query_log.jsonl``,
``audit_log.jsonl``) before it is rotated. ``<= 0`` disables rotation
(the file grows without bound). Read at call time by
``logs.logger._rotation_settings()``, per this project's
read-through-``cfg.settings``-at-call-time convention -- this field was
previously read directly via ``os.getenv`` in ``logs/logger.py``
because ``config.py`` was locked by concurrent work when that module
was written; it is free now, so the field lives here like every other
setting.
Raised from ``10 MiB`` alongside :attr:`log_backup_count` below —
together they bound how much audit history a rotation can ever discard
(see that field's docstring for the arithmetic and the incident that
prompted it). Raise this further on a deployment with materially higher
traffic than the ~1 KB/record this project has actually observed;
lower it only if disk space is the tighter constraint than audit
history, which for a compliance/analysis log is rarely the right
trade."""
log_backup_count: int = field(
default_factory=lambda: int(os.getenv("LOG_BACKUP_COUNT", "20"))
)
"""Number of rotated log backups to retain. ``<= 0`` keeps no history
(the file is cleared in place instead of shifted to ``.1`` on
rotation). See :attr:`log_max_bytes` for why this lives here rather
than behind a direct ``os.getenv`` call.
Raised from ``5`` together with :attr:`log_max_bytes`'s five-fold
increase (``10 MiB -> 50 MiB``): a first production deployment's
``audit_log.jsonl`` is the only source this project has ever had for
real accuracy/latency numbers, and losing its earliest records to a
rotation nobody was watching would be silent and unrecoverable —
there is no second chance at "the first week". Observed audit records
average roughly 1 KB each (per-stage timings, the full ``llm`` status
block, guard verdict); the old ``10 MiB x 5`` = 50 MiB ceiling (about
50,000 records total) could plausibly be exhausted within the first
production week by traffic well short of anything unusual, especially
stacked on top of whatever pre-deployment dev/test traffic already
shares the same file. The new ``50 MiB x 20`` = 1 GiB ceiling (on the
order of a million records) is not "unbounded" — a genuinely runaway
write loop still eventually rotates its oldest history away rather
than filling the disk forever — but comfortably outlasts any real
single-organisation deployment's first weeks. Reassess this number
once real production volume is known; do not simply raise it further
"to be safe" without knowing the actual record rate, since that trades
away the runaway-growth backstop for no measured benefit."""
# ── Phase 2: static prompt prefix / prefix-cache latency win ────────────
prompt_retrieval_token_budget: int = field(
default_factory=lambda: int(os.getenv("PROMPT_RETRIEVAL_TOKEN_BUDGET", "6000"))
)
"""Token-estimate threshold (see ``prompt_engine.static_prefix.estimate_tokens``)
above which :class:`~prompt_engine.builder.PromptBuilder` falls back to
per-question retrieval instead of the static, byte-identical prefix. The
knowledge base measured at Phase 2 kickoff (system prompt + full schema +
relationships + business rules + examples) is ~4.6k real tokens, comfortably
under this default — today's 12-table schema always takes the static path.
A larger schema added in a later phase can exceed the budget and
transparently falls back to the six-retriever pipeline (still exercised,
never dead code) instead of blowing up the prompt or losing accuracy."""
# ── Table retrieval: recall knobs (docs/design/RETRIEVAL.md) ────────────
retrieval_extra_tables: int = field(
default_factory=lambda: int(os.getenv("RETRIEVAL_EXTRA_TABLES", "3"))
)
"""How many tables ranked by description and column evidence may be added
to the ones a configured alias or fact pattern already named
(``retrieval.entity_retriever`` / ``retrieval.fact_retriever``). An alias
hit used to end the search for that kind of table, so a question naming
one aliased dimension and one that has no alias lost the second. ``0``
restores that behaviour."""
retrieval_extra_score_ratio: float = field(
default_factory=lambda: float(os.getenv("RETRIEVAL_EXTRA_SCORE_RATIO", "0.5"))
)
"""An extra table (see :attr:`retrieval_extra_tables`) must score at least
this fraction of the best table of its kind. Higher is stricter: fewer
extras, higher precision."""
retrieval_join_expansion: bool = field(
default_factory=lambda: os.getenv("RETRIEVAL_JOIN_EXPANSION", "true").lower()
in ("1", "true", "yes")
)
"""When ``True``, the tables a question needs to be joined to each other
but did not name (a bridge table, the dimension a classification hangs
from) are added to the retrieved set, found on the foreign-key graph
(``schema.yaml`` relationships, ``relationships.yaml`` and, see
:attr:`retrieval_infer_relationships`, the ``<Table>_ID`` naming
convention). See ``retrieval.join_paths``."""
retrieval_join_max_hops: int = field(
default_factory=lambda: int(os.getenv("RETRIEVAL_JOIN_MAX_HOPS", "2"))
)
"""Longest join (in foreign keys) :attr:`retrieval_join_expansion` will
bridge between two retrieved tables; ``2`` allows one intermediate table
(fact - bridge - dimension). A pair further apart is left unconnected
rather than pulling in a chain. On the synthetic benchmark ``3`` and ``4``
found nothing more and cost 0.26 and 0.53 more tables per question
(docs/design/RETRIEVAL.md); raise it if your schema chains bridges."""
retrieval_join_max_added_tables: int = field(
default_factory=lambda: int(os.getenv("RETRIEVAL_JOIN_MAX_ADDED_TABLES", "4"))
)
"""Most tables :attr:`retrieval_join_expansion` adds to one question's
retrieved set. A path that would exceed it, or push the schema block past
:attr:`prompt_retrieval_token_budget`, is skipped."""
retrieval_join_max_hub_degree: int = field(
default_factory=lambda: int(os.getenv("RETRIEVAL_JOIN_MAX_HUB_DEGREE", "10"))
)
"""A table that more than this many tables reference (a calendar or
currency dimension shared by every fact) is never used as a stepping
stone between two other tables; it still joins when the question names it."""
retrieval_infer_relationships: bool = field(
default_factory=lambda: os.getenv("RETRIEVAL_INFER_RELATIONSHIPS", "true").lower()
in ("1", "true", "yes")
)
"""When ``True``, a column named ``<Table>_ID`` / ``<Table>ID`` is taken as a
foreign key to that table's ``ID`` for join-path expansion when neither
``schema.yaml`` nor ``relationships.yaml`` declares the edge. Used for
retrieval only; nothing is written to configuration and no join hint is
added to the prompt."""
retrieval_prune: bool = field(
default_factory=lambda: os.getenv("RETRIEVAL_PRUNE", "true").lower()
in ("1", "true", "yes")
)
"""When ``True``, the candidates of the entity and fact retrievers go
through ``retrieval.pruning`` before join-path expansion: a table that only
its description matched, or a column match well below the best one, is
dropped unless it is joined to a better-evidenced table. Recall of the
candidate generation is bought with extra tables; this takes the ones the
evidence does not support back out."""
retrieval_prune_score_ratio: float = field(
default_factory=lambda: float(os.getenv("RETRIEVAL_PRUNE_SCORE_RATIO", "0.85"))
)
"""A table with column-name evidence must score at least this fraction of
the best unforced score to count as an anchor on its own; below it, it
survives only by being joined to an anchor. ``0`` keeps every such table."""
retrieval_prune_connect_hops: int = field(
default_factory=lambda: int(os.getenv("RETRIEVAL_PRUNE_CONNECT_HOPS", "2"))
)
"""How many foreign keys may separate a weakly evidenced table from an
anchor for it to count as joined (``2`` reaches a table through a bridge).
``0`` disables the rescue."""
retrieval_prune_corroborate: bool = field(
default_factory=lambda: os.getenv("RETRIEVAL_PRUNE_CORROBORATE", "true").lower()
in ("1", "true", "yes")
)
"""When ``True``, two candidates that no anchor supports but that are joined
to each other (within :attr:`retrieval_prune_connect_hops`) keep each other:
each was matched on its own and the join graph agrees. It saves the tables of
a question whose strongest alias hit is a false one (an alias that is a
substring of the word the question actually uses)."""
# ── Phase 2: deterministic decoding (docs/api-contract-v2.md §6) ───────
llm_temperature: float = field(
default_factory=lambda: float(os.getenv("LLM_TEMPERATURE", "0.0"))
)
"""Sampling temperature sent to the backend. ``0.0`` (the default) makes
decoding deterministic given a fixed seed — required so that the same
question produces byte-identical SQL on every run."""
llm_top_p: float = field(
default_factory=lambda: float(os.getenv("LLM_TOP_P", "1.0"))
)
"""Nucleus-sampling cutoff. ``1.0`` disables nucleus sampling, which
matters for determinism only when combined with ``llm_temperature=0.0``
and a fixed ``llm_seed``."""
llm_seed: int = field(
default_factory=lambda: int(os.getenv("LLM_SEED", "7"))
)
"""Fixed sampling seed, sent on every request. Honoured by vLLM and
llama.cpp; accepted but not guaranteed-deterministic by OpenAI's own
hosted API (see its ``system_fingerprint`` field) — this project sends
it regardless (never harmful) and reports whether the endpoint *claims*
to honour it in the ``llm`` status block's ``seed_honored`` rather than
asserting determinism this process cannot verify on its own. Combined
with ``temperature=0`` and a genuinely deterministic endpoint, this is
what makes ``the same question, run twice, produce byte-identical SQL``
(a Phase 2 exit criterion) rather than merely "usually similar"."""
llm_num_predict: int = field(
default_factory=lambda: int(os.getenv("LLM_NUM_PREDICT", "512"))
)
"""Bounded max completion length (sent as ``max_tokens``). A generated
SQL statement is a few dozen tokens at most; capping this bounds worst-case
decode latency and protects against a runaway generation looping forever."""
llm_extra_body_json: str = field(
default_factory=lambda: os.getenv("LLM_EXTRA_BODY", "")
)
"""Extra JSON fields merged into every chat-completions request body, as
a JSON object. Empty (the default) sends exactly what this project has
always sent.
This exists because "turn the model's reasoning off" has no place in
the OpenAI chat-completions schema, and every server spells it
differently: vLLM and SGLang take
``{"chat_template_kwargs": {"enable_thinking": false}}`` for Qwen3,
Ollama takes ``{"think": false}``, and others use ``reasoning_effort``.
Encoding those dialects here would mean this project claiming to know
every inference server's private vocabulary, and silently sending the
wrong key whenever it guessed wrong. A passthrough says the honest
thing instead: these are your server's fields, sent verbatim.
It matters more than a tuning knob suggests. A reasoning model spends
its completion budget thinking before it answers, so with
:attr:`llm_num_predict` at its default a Qwen3-class model can consume
every token on reasoning and be cut off (``finish_reason: "length"``)
before emitting a single character of SQL — which arrives as
``EMPTY_SQL_RESPONSE``, a message about the response rather than about
the cause.
Keys that would let this field change *which model is asked what* are
rejected at parse time rather than merged — see
:data:`llm.providers.RESERVED_PAYLOAD_KEYS`. Everything else is the
operator's business, not this module's."""
llm_stop: tuple[str, ...] = field(default_factory=tuple)
"""Optional stop sequences appended to every request's ``stop`` field.
Empty by default — most models terminate cleanly at the SQL statement's
natural end; set this only if a specific model needs an explicit fence."""
# ── Phase 2: query-result cache normalisation ───────────────────────────
cache_normalize_questions: bool = field(
default_factory=lambda: os.getenv("CACHE_NORMALIZE_QUESTIONS", "true").lower()
not in ("0", "false", "no")
)
"""When true (default), cache keys are built from a normalised form of
the question (collapsed whitespace, Persian/Arabic digits folded to
ASCII, ZWNJ stripped, ي/ك folded to ی/ک) so trivially different
phrasings of the same question share a cache entry. See
``api.query_cache._normalize_question``."""
# ── Phase 2: LLM router / endpoint-trust governance ─────────────────────
llm_provider: str = field(
default_factory=lambda: os.getenv("LLM_PROVIDER", "openai")
)
"""One of ``"openai"`` (the default — route through :mod:`llm.endpoints`)
or ``"mock"`` (every task answered by :class:`~llm.providers.MockBackend`,
no endpoint configuration needed — for tests and offline runs). See
``llm.router.LLMRouter.from_settings``."""
llm_allow_remote: bool = field(
default_factory=lambda: os.getenv("LLM_ALLOW_REMOTE", "false").lower()
in ("1", "true", "yes")
)
"""Explicit per-deployment opt-in gate for sending schema, business
rules, or query-result rows to an untrusted (not on this deployment's
own infrastructure) LLM endpoint. This product's premise is "runs on
your infrastructure" — pointing ``OPENAI_BASE_URL``/``LLM_ENDPOINTS``
at a hosted API alone must never be sufficient to start exfiltrating
data; this flag is the separate, deliberate switch, and every call to
an untrusted endpoint made while it is true is written to the audit
trail (see ``llm.router.LLMRouter``). Defaults to ``False``."""
llm_trusted: bool | None = field(
default_factory=lambda: (
{"true": True, "1": True, "yes": True, "false": False, "0": False, "no": False}
.get(os.getenv("LLM_TRUSTED", "").strip().lower())
)
)
"""Explicit trust override for the single ``"default"`` endpoint (see
:attr:`openai_base_url`). ``None`` (unset — the default) defers to
:func:`~llm.trust.default_trust_for_url`, which trusts loopback/private/
``.local`` addresses and nothing else. Set to ``true``/``false`` to
override that heuristic explicitly — e.g. a local endpoint reachable
under a public-looking hostname that should still count as trusted, or
a loopback endpoint that should not. Endpoints declared in
:attr:`llm_endpoints_json` each carry their own independent ``trusted``
override instead of sharing this one."""
llm_endpoints_json: str = field(
default_factory=lambda: os.getenv("LLM_ENDPOINTS", "")
)
"""Additional named endpoints beyond the trivial ``"default"`` one, as
a JSON array of ``{"name", "base_url", "model", "api_key", "trusted"}``
objects (``api_key``/``trusted`` optional). Empty (the default) means
only ``"default"`` exists. See :mod:`llm.endpoints`."""
llm_routes_json: str = field(
default_factory=lambda: os.getenv("LLM_ROUTES", "")
)
"""Per-task endpoint fallback chains, as a JSON object mapping a
:class:`~llm.router.TaskType` value (``"sql_generation"``,
``"interpretation"``, ``"assumption_extraction"``) to a list of
endpoint names tried in order. Empty (the default) routes every task to
``["default"]`` alone. See :mod:`llm.endpoints`."""
llm_task_budget_seconds: float | None = field(
default_factory=lambda: (
float(os.getenv("LLM_TASK_BUDGET_SECONDS", ""))
if os.getenv("LLM_TASK_BUDGET_SECONDS", "").strip()
else None
)
)
"""Per-task latency budget (seconds) applied uniformly to every
:class:`~llm.router.TaskType` by :meth:`~llm.router.LLMRouter.from_settings`.
``None`` (default, unset) disables the budget check entirely — see
``llm.router.LLMRouter._call_chain``."""
# ── Latency: prefix-cache warm-up and the split llm stage ───────────────
llm_prefix_warmup_on_startup: bool = field(
default_factory=lambda: os.getenv(
"LLM_PREFIX_WARMUP_ON_STARTUP", "true",
).lower() in ("1", "true", "yes")
)
"""When ``True``, ``api/server.py``'s ``lifespan`` starts a background
thread at startup that sends the model server one minimal request per
data source whose prompt uses the static prefix
(:func:`~prompt_engine.static_prefix.should_use_static_prefix`): the
messages are exactly the cacheable prefix real requests start with and
``max_tokens`` is ``1``. The server computes and keeps that prefix's
KV cache, so the first real question after a restart does not pay the
full prefill (about a minute on a large local model) -- see
:mod:`llm.warmup`.
Defaults to ``True``. It is safe as a default because it does nothing
unless there is something to warm: a deployment whose schema is over
``PROMPT_RETRIEVAL_TOKEN_BUDGET`` (the retrieval path, no stable
prefix), ``LLM_PROVIDER=mock``, and an endpoint that is not trusted
(``LLM_ALLOW_REMOTE`` is the same gate real requests pass) all skip it.
It never blocks startup (the server accepts requests at once; a
question that arrives mid-warm-up simply runs as it would have),
and every failure is logged and swallowed. It only helps if the
model server caches prefixes (``vllm serve --enable-prefix-caching``,
on by default in recent vLLM, or llama.cpp's prompt cache); on a
server that does not, it costs one extra prefill at startup and
nothing more. Set to ``false`` for a deployment whose model server is
shared and billed per token, or whose startup must send no LLM
traffic. ``POST /admin/llm/warmup`` runs the same warm-up on demand,
whatever this is set to."""
llm_prefix_warmup_timeout_seconds: float = field(
default_factory=lambda: float(os.getenv("LLM_PREFIX_WARMUP_TIMEOUT_SECONDS", "180"))
)
"""Total time budget (seconds) for one warm-up pass, across all data
sources. Each source's request gets whatever is left of the budget as
its HTTP timeout, and a source reached after the budget is spent is
skipped (logged, not retried). The request is never retried: a warm-up
that fails is a warm-up the next real question replaces. Must be
greater than zero. The default is three times the ~1 minute first-
question prefill that motivated the feature."""
llm_prefix_keepwarm_seconds: float = field(
default_factory=lambda: float(os.getenv("LLM_PREFIX_KEEPWARM_SECONDS", "").strip() or "0")
)
"""Seconds between keep-warm passes; ``0`` (the default) turns it off.
The start-up warm-up (:attr:`llm_prefix_warmup_on_startup`) primes the
model server's prefix cache once. On a model server shared with other
users that cache is evicted again whenever their traffic needs the
memory, and the next question pays the full prefill of the prefix --
about a minute on a large local model. With a value above zero a
background thread re-sends each static-prefix source's prefix (the
request the warm-up sends: the prefix alone, ``max_tokens=1``) every
that many seconds, so the prefix never sits unused for long enough to
be the oldest thing in the cache.
It costs nothing under traffic: a source whose prefix a real request
used within the last interval is skipped, because that request already
refreshed it. Idle, it costs one cached-prefix lookup and one generated
token per source per pass. It obeys every rule the start-up warm-up
does: nothing for a source on the retrieval path, nothing under
``LLM_PROVIDER=mock``, nothing to an endpoint that is not trusted unless
``LLM_ALLOW_REMOTE`` is on; every failure is logged once per streak and
never fatal. It pauses while maintenance mode is on and stops at
shutdown. Its last run is on ``GET /admin/llm/warmup``.
Must be ``0`` or at least 30: a shorter interval is a load generator,
not a keep-warm. Runbook section 20.7 recommends a value."""
llm_cache_suspect_ms: int = field(
default_factory=lambda: int(os.getenv("LLM_CACHE_SUSPECT_MS", "").strip() or "50")
)
"""Reply time, in milliseconds, under which a repeated SQL text is taken as
a sign of a response cache in front of the model; ``0`` switches the early
warning off.
A question whose generated SQL is byte-identical to the SQL of a
*different* question seen shortly before, and whose model reply took less
than this, makes the server log one ``WARNING`` per streak and write
``suspected_cached_response: true`` in the audit record's ``llm`` block
(``observability/response_cache_watch.py``). It never blocks or changes a
request: two questions can legitimately map to the same SQL. Generating a
statement takes longer than 50 ms on any real model server, so a reply
that fast is not a generation. Runbook section 20.10."""
llm_stream_timings: bool = field(
default_factory=lambda: os.getenv("LLM_STREAM_TIMINGS", "false").lower()
in ("1", "true", "yes")
)
"""When ``True``, ``llm.providers.OpenAIBackend`` sends each
chat-completions request with ``stream=true`` (and
``stream_options={"include_usage": true}``) and reassembles the
streamed chunks into the same response it would have received without
streaming -- same ``content``, reasoning text, ``finish_reason`` and
``usage`` -- while timing the first token. The audit ``llm`` block then
carries ``ttft_ms`` (queue plus prefill) and ``generation_ms`` (first
token to last) alongside ``total_ms``, which is the only way to tell a
cold prefix cache or a busy server from a long generation, and
reasoning from answer.
Defaults to ``False``: the non-streaming request is what this project
has always sent, and the streamed one differs in one way a deployment
may notice -- the request timeout then bounds the gap between chunks
as well as the whole call (see
:meth:`llm.providers.OpenAIBackend.generate_with_meta`), and some
proxies buffer or drop server-sent events. Turn it on to measure,
compare ``total_ms`` with it off, and leave it on if nothing changes
(it should not). Applies to free-text generation; the constrained
(``LLM_STRUCTURED_OUTPUT``) request is not streamed."""
# ── Phase 3: conversational sessions (docs/api-contract-v2.md §9) ──────
session_ttl_seconds: int = field(
default_factory=lambda: int(os.getenv("SESSION_TTL_SECONDS", "1800"))
)
"""Idle-expiry window (seconds) for an in-memory conversational session
(``session.store.SessionStore``). A session not touched (no new turn,
no transcript read) for this long is evicted, freeing its cached
turns/memory. ``0`` disables sessions entirely (every lookup misses)."""
session_max_count: int = field(
default_factory=lambda: int(os.getenv("SESSION_MAX_COUNT", "500"))
)
"""Maximum number of concurrent sessions kept in memory. Beyond this,
the least-recently-active session is evicted to make room for a new
one — the same LRU discipline ``api.query_cache.QueryCache`` already
applies to cached responses."""
session_max_turns: int = field(
default_factory=lambda: int(os.getenv("SESSION_MAX_TURNS", "50"))
)
"""Transcript cap per session. Beyond this many turns, the oldest turn
(and its session-memory sidecar) is dropped from the session so a
long-lived conversation cannot grow without bound."""
session_prompt_turns: int = field(
default_factory=lambda: int(os.getenv("SESSION_PROMPT_TURNS", "3"))
)
"""How many of the most recent turns are rendered into the prompt's
session-context block (question, SQL, result *column names* — never
row data; see ``docs/api-contract-v2.md`` §8). Older turns fall out of
the prompt window but remain readable via ``GET /v2/sessions/{sid}``."""
refinement_scan_cap: int = field(
default_factory=lambda: int(
os.getenv(
"REFINEMENT_SCAN_CAP",
str(int(os.getenv("MAX_ROWS_RETURNED", "1000")) * 100),
)
)
)
"""§2's inner-scan bound: when a refining turn drops the previous
turn's display ``TOP`` to compute a result over *all* matching rows
(not just the ones previously shown), the resulting unbounded scan is
re-capped at this many rows instead of being left unbounded. Defaults
to ``max_rows_returned * 100``, mirroring ``default_top_n``'s own
fallback-to-``MAX_ROWS_RETURNED`` pattern above."""
# ── Phase 9: session persistence + cross-session memory ─────────────────
session_store_path: str = field(
default_factory=lambda: os.getenv("SESSION_STORE_PATH", "logs/sessions.db")
)
"""SQLite file backing :class:`session.persistence.SessionPersistence`
(question, generated SQL, result column names/row_count/truncated, the
``TurnMemory`` sidecar, and memory entries — never result rows; see that
module's docstring). Defaults to ``logs/sessions.db`` — ``*.db`` is
already gitignored, and ``logs/`` is where the audit log already lives,
so this introduces no new category of stored data. Empty string
disables persistence entirely: ``session.store.SessionStore`` then
behaves exactly as it did before this phase (TTL expiry deletes, no
rehydration, ``GET /v2/sessions`` reflects only the in-memory hot set).
Read once, at ``api.v2_routes.get_session_store``'s lazy-construction
time, like every other singleton-constructor setting in this codebase
(``session_ttl_seconds``, ``session_max_count``, ...)."""
session_retention_days: int = field(
default_factory=lambda: int(os.getenv("SESSION_RETENTION_DAYS", "30"))
)
"""How long (days) a persisted conversation stays listable and
reopenable — the third, previously-conflated lifetime alongside
:attr:`session_prompt_turns` (the prompt window) and
:attr:`session_ttl_seconds` (how long the in-memory record stays hot).
``session.store.SessionStore.purge_expired`` permanently deletes a
persisted session past this many days since its ``last_active_at``;
it does not affect the (much shorter) in-memory TTL, which only
demotes a session out of memory, never deletes it, when persistence is
attached."""
session_title_max_length: int = field(
default_factory=lambda: int(os.getenv("SESSION_TITLE_MAX_LENGTH", "80"))
)
"""Cap on a session title's length — both the auto-derived title (the
first question, truncated at a word boundary) and a title supplied via
``PATCH /v2/sessions/{sid}``. A title is presentation only: it is
validated under the same length/control-character rules as a memory
value (see :attr:`memory_value_max_length`) precisely because it is
user text of the same shape, but it never enters a prompt."""
memory_enabled: bool = field(
default_factory=lambda: os.getenv("MEMORY_ENABLED", "true").lower()
not in ("0", "false", "no")
)
"""Master switch for cross-session memory (``docs/api-contract-v2.md``
§5). When ``False``, a stored entry is never applied to a turn's
assumptions/filters (``session.engine.TurnEngine.ask`` skips
:func:`session.memory.apply_memory_to_assumptions` entirely) — the
``GET``/``PUT``/``DELETE /v2/memory*`` endpoints themselves are
unaffected, so an operator can disable the feature's *effect* without
losing an analyst's already-pinned entries."""
memory_max_entries_per_principal: int = field(
default_factory=lambda: int(os.getenv("MEMORY_MAX_ENTRIES_PER_PRINCIPAL", "20"))
)
"""Cap on how many distinct memory keys one principal may have pinned
at once. Exceeding it on ``PUT /v2/memory/{key}`` (a *new* key; updating
an already-pinned key never counts against this cap) is an explicit
422, never a silent eviction of an older entry — see
``docs/api-contract-v2.md`` §5."""
memory_value_max_length: int = field(
default_factory=lambda: int(os.getenv("MEMORY_VALUE_MAX_LENGTH", "120"))
)
"""Default per-key cap on a memory value's length, in
``project_config/memory_policy.yaml``'s absence of a narrower
``max_length`` for that specific key. A memory value is untrusted text
that reaches the prompt's variable suffix (never the static prefix),
so it is also rejected outright for a newline or control character —
see ``session.memory.validate_memory_value``."""
cors_allowed_origins: tuple[str, ...] = field(
default_factory=lambda: tuple(
o.strip() for o in os.getenv("CORS_ALLOWED_ORIGINS", "").split(",") if o.strip()
)
or DEFAULT_CORS_ALLOWED_ORIGINS
)
"""Origins allowed to call this API cross-origin (``CORSMiddleware`` in
``api/server.py``), comma-separated.
Defaults to :data:`DEFAULT_CORS_ALLOWED_ORIGINS` — the loopback
origins the bundled ``web/`` UI is documented to be served from.
Setting the variable replaces that list entirely rather than adding
to it, so a real deployment naming its own origin does not silently
keep localhost allowed as well.
Same-origin requests never need CORS at all, so this only matters for
the split-origin layout ``web/README.md`` and
``docs/fa/getting-started.md`` describe: the API on one port, the
static UI on another."""
llm_structured_output: bool = field(
default_factory=lambda: os.getenv("LLM_STRUCTURED_OUTPUT", "false").lower()
in ("1", "true", "yes")
)
"""Phase 2 task 3 feature flag: when ``True``, SQL generation asks for
a single constrained JSON object (``{"sql", "out_of_scope",
"confidence", "assumptions"}``, see ``llm.structured_schema``) instead
of free text plus ``clean_sql`` regex surgery. Defaults to ``False``
until the golden-set evaluation (``python -m eval.cli run --live
--structured``) demonstrates it does not regress accuracy — see the
Phase 2 report for whether that evaluation has been run yet."""
# ── Deployment readiness: per-(principal, ip) rate limiting ─────────────
# Previously ``api/middleware.py``'s own module-level ``os.getenv()``
# reads, evaluated once at that module's import time -- which is why
# ``tests/conftest.py`` had to set these env vars *before* anything
# imported ``api.middleware`` at all (see that file's own comment).
# Moved here so the values are read through ``cfg.settings`` at call
# time like every other tuning knob (see this module's "Three layers,
# not two" section) -- ``RateLimitMiddleware.__init__`` now resolves
# them itself when it is actually constructed, so
# ``config.override_settings(rate_limit_requests=...)`` reaches a
# middleware instance built with no explicit constructor kwargs, not
# just one a test hand-configures. See ``api/middleware.py``'s module
# docstring for why the *shared* ``api.server.app`` instance still
# needs its generous test-suite value in place before the first
# request of a whole pytest session, not merely "at some point before
# a given test" -- Starlette builds and caches that app's middleware
# stack exactly once, on the first request it ever serves.
rate_limit_requests: int = field(
default_factory=lambda: int(os.getenv("RATE_LIMIT_REQUESTS", "600"))
)
"""Sustained request allowance per :attr:`rate_limit_window_seconds`,
per rate-limit bucket (see :attr:`rate_limit_burst` for capacity above
this, and ``api.middleware.RateLimitMiddleware._bucket_key`` for what a
"bucket" is).
Raise this if legitimate traffic is getting 429s under normal use
(more concurrent analysts, or a UI that legitimately fires several
requests per interaction); lower it if a single caller should be
throttled harder than this — e.g. a deployment that expects only a
handful of analysts and wants a tighter ceiling on a misbehaving
client or a scripting mistake.
Old default was ``60`` — chosen back when the bucket was keyed on raw
IP alone. In the shape this product actually ships in (one web UI,
one shared service key, ten-plus analysts sitting behind it), the
bucket key becomes ``principal:<id>|ip:<ip>`` (see
``RateLimitMiddleware._bucket_key``'s docstring) but a single web UI
still means every analyst using it shares **one IP** too — so ``60``
req/min was, in practice, 60 requests per minute for the *entire
organisation*, not per analyst. Ten people each asking one question
every few seconds blew through it. ``600`` (10 req/sec sustained)
assumes up to roughly 30 analysts each firing an interactive query —
a human clicking "ask" every few seconds, not a batch client — at
once: 30 analysts x one request every 3s is exactly 600/min. A
deployment with a different expected head-count should raise or
lower this proportionally rather than trust the default blindly."""
rate_limit_window_seconds: float = field(
default_factory=lambda: float(os.getenv("RATE_LIMIT_WINDOW_SEC", "60"))
)
"""Length (seconds) of the sliding window :attr:`rate_limit_requests`
refills over. Unchanged from the original default (``60``) — the
*rate* (:attr:`rate_limit_requests` per this many seconds) is what
needed raising for real deployment traffic, not the window length
itself; a shorter window makes the same requests/window ratio bursts
tighter over any sub-window, a longer one loosens it, but ``60`` is a
reasonable "per minute" unit for a human operator reading a 429 body
to reason about either way."""
rate_limit_burst: int = field(
default_factory=lambda: int(os.getenv("RATE_LIMIT_BURST", "40"))
)
"""Extra tokens above :attr:`rate_limit_requests` a bucket can spend
instantly (bucket capacity = ``rate_limit_requests + rate_limit_burst``
— see ``api.middleware.RateLimitMiddleware``). Old default was ``10``.
Raised to ``40`` alongside :attr:`rate_limit_requests`'s five-fold
increase so the burst allowance scales with it — enough to absorb a
web UI firing a handful of parallel calls on one page load (e.g. an
initial dashboard fetch) without immediately eating into the
sustained rate, while staying far short of "unlimited": a genuinely
runaway or scripted caller still exhausts 640 tokens (600 + 40) in
well under a minute at any real request rate and starts getting 429s,
it just is not punished for one legitimate burst."""
# ── Admin panel, phase 2: the application database ──────────────────────
app_db_url: str = field(
default_factory=lambda: os.getenv("APP_DB_URL", "")
)
"""A SQLAlchemy URL for the application database — the key store, role
grants, and (see :mod:`session.persistence`) conversational sessions.
Empty (the default) falls back to a SQLite file at
:attr:`app_db_sqlite_path`, created automatically. A configured value
is expected to name a database that **already exists** — this
application creates tables inside it, never the database itself (see
``docs/admin-panel-architecture.md`` §5.3): ``CREATE DATABASE`` needs
rights a DBA will not grant an application.
Must never resolve to the same server+database as
:attr:`db_connection_url` — or, with :mod:`database.datasources`
configured (``project_config/datasources.yaml``), any configured
warehouse data source — :func:`appdb.engine.raise_if_same_database` is
checked at start-up (``api/server.py``'s ``lifespan``, once per
source) and refuses to start otherwise, because every warehouse
connection is deliberately read-only (``docs/db-hardening.md``) and
the application database needs writes."""
app_db_sqlite_path: str = field(
default_factory=lambda: os.getenv("APP_DB_SQLITE_PATH", "logs/app.db")
)
"""SQLite file used when :attr:`app_db_url` is unset. Defaults to
``logs/app.db`` — alongside ``logs/sessions.db`` and the audit log,
introducing no new category of stored-data location. ``*.db`` is
already gitignored (see ``tests/test_no_runtime_artifacts_tracked.py``)."""
key_cache_ttl_seconds: float = field(
default_factory=lambda: float(os.getenv("KEY_CACHE_TTL_SECONDS", "5.0"))
)
"""How long (seconds) the in-memory API-key cache
(:mod:`appdb.key_store`) serves a database-backed key set before
re-querying. Keys move into the application database precisely so a
revoked/disabled key can be shut off without a restart
(``docs/admin-panel-architecture.md`` §5.5/§5.6) — reading the key
table on every single request would mean a network round trip to the
application database per request, so this cache exists to avoid that.
Every mutation (issue/disable/enable/revoke/ACL change/role grant)
invalidates the cache explicitly rather than waiting out this TTL, so
it bounds only the *unforced* staleness window — a revocation always
takes effect on the very next request regardless of this value. Kept
short by default because it is a safety-relevant staleness bound, not
a raw performance knob; raise it only if the application database is
under measured load from key lookups alone."""
# ── Phase 8: API-key authentication ─────────────────────────────────────
api_keys_json: str = field(
default_factory=lambda: os.getenv("API_KEYS_JSON", "")
)
"""JSON array of ``{"id", "name", "key_sha256", "denied_columns"?}``
objects — the deployment's configured API keys. Only the SHA-256 hex
digest of a key is ever stored here, never the raw key itself; issue
new keys with ``scripts/issue_api_key.py``. Parsed at call time by
:func:`security.auth.load_api_keys`, per this module's
read-through-``cfg.settings``-at-call-time convention. Empty (the
default) means no caller can authenticate — see :attr:`auth_required`
for what that implies at startup. A multi-line value only survives
``.env`` wrapped in single quotes with no apostrophe inside, so for more
than one key prefer :attr:`api_keys_file`. Setting both is refused."""
api_keys_file: str = field(
default_factory=lambda: os.getenv("API_KEYS_FILE", "")
)
"""Path to a file holding exactly what :attr:`api_keys_json` holds: the
JSON array of key objects, in any formatting, with no ``.env`` quoting
rules to trip over. Recommended location: ``project_config/api_keys.json``
(``project_config/`` is git-ignored). A relative path resolves against the
repository root, like ``PROJECT_CONFIG_DIR``, not the working directory.
Empty (the default) means unused. Read once per process, like an
environment variable, so edits need a restart. A missing or unreadable
file, invalid JSON, a repeated key inside one object, or both this and
:attr:`api_keys_json` being set is refused at startup. Read by
:func:`security.auth.load_api_keys`."""
auth_required: bool = field(
default_factory=lambda: os.getenv("AUTH_REQUIRED", "true").lower()
not in ("0", "false", "no")
)
"""Whether every non-``/health`` route requires a valid API key.
Defaults to ``True`` — this is a fail-closed system, not a fail-open
one. Setting this to ``False`` is a deliberate escape hatch (local
development, an isolated network with its own perimeter security)
that ``api/server.py``'s ``lifespan`` logs a ``WARNING`` for on
*every* startup, not just the first, so a silently-disabled front
door is never quiet in the logs. When ``True`` and :attr:`api_keys_json`
/ :attr:`api_keys_file` resolve to zero configured keys, ``lifespan``
raises ``RuntimeError`` instead of starting a server nobody could ever
authenticate to."""
app_docs_public: bool = field(
default_factory=lambda: os.getenv("APP_DOCS_PUBLIC", "false").lower()
in ("1", "true", "yes")
)
"""When ``False`` (the default), ``/docs``, ``/redoc``, and
``/openapi.json`` require the same authentication as every other
non-``/health`` route — the generated API documentation describes
exactly what a caller can do to production data, which is not
something to publish to an unauthenticated network. Set ``True`` to
serve them without credentials (e.g. a deployment that already sits
behind its own perimeter auth)."""
# ── Phase 5b: deterministic value resolution ────────────────────────────
resolve_value_timeout_seconds: float = field(
default_factory=lambda: float(os.getenv("RESOLVE_VALUE_TIMEOUT_SECONDS", "2.0"))
)
"""Per-(table, column) deadline for ``retrieval.value_resolver.resolve_value``'s
database round trip. Deliberately much tighter than
:attr:`query_timeout_seconds` (which bounds a full analytic query) —
this is a small lookup on the critical path *before* SQL generation
even starts, so a breach falls back to the no-match path (see
``resolve_value``'s docstring) rather than blocking the request for as
long as a real query is allowed to run."""
resolve_value_max_concurrency: int = field(
default_factory=lambda: int(os.getenv("RESOLVE_VALUE_MAX_CONCURRENCY", "8"))
)
"""How many value-resolution queries may be in flight at once,
process-wide. This is the bound a ``ThreadPoolExecutor``'s
``max_workers`` used to provide, before
``retrieval.value_resolver._run_under_deadline`` replaced that pool
with daemon threads (see its docstring for why it had to). Read at
call time, so it responds to ``override_settings``. Waiting for a
free slot spends the caller's own
:attr:`resolve_value_timeout_seconds`: a saturated resolver must
report a miss on time rather than queue past its deadline."""
resolve_value_cache_ttl_seconds: int = field(
default_factory=lambda: int(os.getenv("RESOLVE_VALUE_CACHE_TTL_SECONDS", "300"))
)
"""How long (seconds) a cached entity-value resolution stays valid.
``<= 0`` disables the cache. Dimension tables (customers, brokers,
symbols, ...) change slowly, so this can reasonably sit much longer
than an individual request without serving stale data in practice."""
resolve_value_cache_max_size: int = field(
default_factory=lambda: int(os.getenv("RESOLVE_VALUE_CACHE_MAX_SIZE", "512"))
)
"""Maximum number of distinct ``(mention, table, column, scope_key)``
resolutions kept in memory before LRU eviction — mirrors
:attr:`cache_max_size`'s discipline for the unrelated query-result
cache."""
dimension_vocabulary_ttl_seconds: int = field(
default_factory=lambda: int(os.getenv("DIMENSION_VOCABULARY_TTL_SECONDS", "3600"))
)
"""How long (seconds) a small dimension's prefetched value set
(``retrieval.dimension_vocabulary``) is served without triggering a
refresh. Unlike :attr:`resolve_value_cache_ttl_seconds`, this is
**not** a per-request latency knob and, since the stale-while-revalidate
redesign, it is **not an expiry either**: a cached value past this TTL
is still served (a stale trading-hall or commodity name beats no name
at all) while a background refresh for it is triggered — see that
module's docstring's "Stale-while-revalidate, warmed lazily" section.
``<= 0`` makes every read stale immediately (a background refresh
fires on every match that consults it, rate-limited on failure by
:data:`retrieval.dimension_vocabulary._BACKGROUND_REFRESH_BACKOFF_SECONDS`),
which is the closest this module comes to "disabled" — it still stores
and serves whatever was last fetched rather than discarding it."""
dimension_vocabulary_warm_on_startup: bool = field(
default_factory=lambda: os.getenv(
"DIMENSION_VOCABULARY_WARM_ON_STARTUP", "true",
).lower() in ("1", "true", "yes")
)
"""When ``True``, ``api/server.py``'s ``lifespan`` calls
``retrieval.dimension_vocabulary.warm_all`` once during startup, before
the server begins accepting requests.
Its meaning changed with the stale-while-revalidate redesign: the
vocabulary cache is no longer inert without this flag — a cold or
stale entry now self-heals via a background refresh triggered from the
request path itself (never awaited, so no request blocks on it; see
``retrieval.dimension_vocabulary``'s module docstring). This flag is
now a pure **optimisation**: it pre-pays the very first cold-cache miss
for each dimension at startup instead of leaving it to whichever
request happens to ask about that dimension first. A deployment that
turns this off still gets working, self-healing dimension resolution
— just with one extra miss per dimension after each restart, and (per
the TTL note above) after each TTL expiry regardless.
Defaults to ``True``: every restart without
it reproduces "the first question that mentions any prefetched
dimension after a cold start silently drops that filter" (self-healing
only for the *next* question, never the one that triggered the
refresh) — a real, reported production symptom, not a theoretical one.
This codebase's test suite is unaffected either way: it builds a
``TestClient(app)`` against no live database, and ``warm_all`` catches
and logs a failure per column rather than raising (see below), so a
startup warm-up attempt against the tests' unmocked/refused connection
degrades to the same "no match" cache state a disabled flag would
leave it in, not a test failure. Set this back to ``false`` only to
restore the old opt-in behaviour (e.g. a deployment whose startup path
must not touch the database at all). A warm-up failure (e.g. the
database is unreachable at startup) is logged as a warning, never
raised — unlike the auth/config checks above this one, an empty
vocabulary cache degrades every dimension it would have covered to "no
match" (this phase's universal safe-miss behaviour), not a server that
cannot start at all."""
dimension_vocabulary_token_fallback_enabled: bool = field(
default_factory=lambda: os.getenv(
"DIMENSION_VOCABULARY_TOKEN_FALLBACK_ENABLED", "true",
).lower() in ("1", "true", "yes")
)
"""When ``True``, ``retrieval.dimension_vocabulary.match_question_against_vocabulary``
falls back to a distinctive-token score (see that module's docstring,
"Matching rules") for a table whose cached value never appears as one
exact contiguous substring of the question — a hall/currency/etc.
named by only part of its stored value (an inserted word, a different
modifier order) rather than the exact string.
Defaults to ``True`` (confirmed root cause:
"dimension_vocabulary matching requires the ENTIRE cached value to
appear as one contiguous substring... no token/partial matching" — the
concrete mechanism behind the reported "the hall named in the question
is ignored" complaint). The fix is additive and cannot regress an
already-working exact match (it only ever runs after that pass finds
nothing), and its scoring only ever counts a value's *distinctive*
tokens (excluding whatever token is common to most of that column's
values), with a tie between two distinct values going to the exact
same clarification path an ambiguous exact match already uses rather
than a silent guess. Set to ``false`` only if a real deployment's
dimension values are short/generic enough that this trades away too
many clean matches for clarifications, or — worst case — a match
against a value whose distinctive words are scattered through an
unrelated question."""
filter_value_check: bool = field(
default_factory=lambda: os.getenv("FILTER_VALUE_CHECK", "false").lower()
in ("1", "true", "yes")
)
"""When ``True``, a statement the guard accepted is checked once, before it
runs, for string literals compared to a column whose whole value set is
cached (a table's ``prefetchable_columns``,
``retrieval.dimension_vocabulary``) that are not in that set. If there
are any, the model gets one correction message that names the column,
says the value does not exist and lists the nearest real values. The
corrected statement runs only if every checked literal in it is known;
otherwise the original statement runs exactly as it would have without
this setting. The check reads the cache only (it never waits on a
database read), a column with no cached vocabulary is not checked, and
it can add one model call to a request but never refuse one. See
``retrieval.filter_values`` and the runbook's section on it.
Defaults to ``False``: with it off no request, prompt, statement, audit
record or response differs from a deployment that never had the check."""
filter_value_spelling: bool = field(
default_factory=lambda: os.getenv("FILTER_VALUE_SPELLING", "true").lower()
in ("1", "true", "yes")
)
"""When ``True``, a statement the guard accepted has its string-literal
filters spelled the way the column stores them, before it runs: a literal
compared to a column whose whole value set is cached (a table's
``prefetchable_columns``) that is not one of its values, but equals exactly
one of them up to Arabic/Persian letter forms (``ي``/``ى`` for ``ی``,
``ك`` for ``ک``, presentation forms, ZWNJ), is replaced by that stored
value; a ``LIKE`` pattern over a column that stores one form of those
letters gets that form. There is no model call, no database read (the cache
only), and a statement the guard refuses after the rewrite runs as it was
written. It runs ahead of ``FILTER_VALUE_CHECK``, so a fixed literal is a
known value and costs no correction round. See ``retrieval.filter_spelling``
and the runbook's section on it.
Defaults to ``True``: the fix acts only on a statement that would
otherwise compare with a value the column does not contain (it returns
nothing under a collation that tells the two spellings apart), and the value
it writes is the column's own, so it cannot match less than the literal did.
Set it to ``false`` and no request, statement, audit record or response
differs from one made before the setting existed."""
# ── Evaluation harness: golden-set regression gate (see eval/baseline.py) ─
eval_max_accuracy_drop_pct: float = field(
default_factory=lambda: float(os.getenv("EVAL_MAX_ACCURACY_DROP_PCT", "5.0"))
)
"""Percentage-point drop in ``accuracy_pct`` (current vs baseline) above
which ``eval.baseline.compare_to_baseline`` treats a run as regressed.
E.g. ``5.0`` means baseline 90% -> current 84% (a 6-point drop) fails,
but baseline 90% -> current 86% (a 4-point drop) does not. A per-deployment
tuning knob rather than a fixed technical fact: how much accuracy
variance is tolerable is a product decision that can reasonably differ
across warehouses. Still overridable per invocation via
``python -m eval.cli run --max-accuracy-drop-pct``, which takes
precedence when passed explicitly."""
eval_max_latency_p95_increase_pct: float = field(
default_factory=lambda: float(os.getenv("EVAL_MAX_LATENCY_P95_INCREASE_PCT", "20.0"))
)
"""Relative percentage increase in ``latency_p95`` (current vs baseline)
above which a run is considered regressed. E.g. ``20.0`` means a
baseline p95 of 2.0s tolerates up to 2.4s before failing. Slower or
more variable hardware legitimately wants a looser tolerance here than
a deployment on fast, dedicated hardware -- this is the textbook
"how aggressively to retry/tolerate for *this* hardware" tuning knob.
Overridable per invocation via
``python -m eval.cli run --max-latency-p95-increase-pct``."""
eval_max_guard_rejection_increase: int = field(
default_factory=lambda: int(os.getenv("EVAL_MAX_GUARD_REJECTION_INCREASE", "0"))
)
"""Absolute increase in ``guard_rejections`` (current vs baseline) above
which a run is considered regressed. Defaults to ``0`` -- any new guard
rejection versus baseline is treated as safety-relevant and flagged,
since it means SQL that used to pass the security guard no longer does
(or the generator started producing worse SQL). Overridable per
invocation via ``python -m eval.cli run --max-guard-rejection-increase``."""
eval_min_accuracy: float | None = field(
default_factory=lambda: _parse_optional_percent("EVAL_MIN_ACCURACY")
)
"""Optional absolute floor (percent, 0-100) on the run's overall
``accuracy_pct``. ``eval_max_accuracy_drop_pct`` only blocks a *relative*
drop against a stored baseline, so a baseline that was already poor (or
a slow slide in steps each smaller than the allowed drop, re-recorded
every time) never trips it. This is the figure a release is not allowed
to go below whatever the baseline says. ``None`` (default, variable
unset or blank) disables the check, so nothing changes until an
operator sets it. Unlike the drop threshold it needs no
``--baseline``. Only meaningful for ``--live`` runs: an offline run
replays the golden set's own ``expected_sql`` and is 100% by
construction. Overridable per invocation via
``python -m eval.cli run --min-accuracy``."""
eval_min_source_accuracy: float | None = field(
default_factory=lambda: _parse_optional_percent("EVAL_MIN_SOURCE_ACCURACY")
)
"""Optional absolute floor (percent, 0-100) applied to *each* data
source's execution accuracy separately (the per-source figures in the
report, built from the golden cases' ``expected_datasource``). An
overall figure can stay high while one small source collapses; this
makes that a failure. When set but the run has no per-source figures
(no golden case names an ``expected_datasource``) the gate fails
rather than pass unchecked. ``None`` (default) disables the check.
Overridable per invocation via ``python -m eval.cli run
--min-source-accuracy``."""
eval_golden_path: str = field(
default_factory=lambda: os.getenv("EVAL_GOLDEN_PATH", "eval_data/golden.jsonl")
)
"""Path to the golden-set ``.jsonl`` file the admin panel's config-version
dry-run (``docs/admin-panel-architecture.md`` §6.2, phase 3 spec §5)
runs a candidate ``project_config/`` bundle against before it can be
applied. Read at call time by :mod:`appdb.config_versions`, the same
``eval_data/golden.jsonl`` a deployment already maintains for
``python -m eval.cli run`` -- this setting exists so the panel can find
it without a request body needing to name a filesystem path. Point this
at ``eval_data.example/golden.jsonl`` (alongside ``PROJECT_CONFIG_DIR``)
to run the dry-run against the committed example data instead."""
eval_baseline_path: str = field(
default_factory=lambda: os.getenv("EVAL_BASELINE_PATH", "eval_data/baseline.json")
)
"""Path to the baseline JSON file ``python -m eval.cli run --save-baseline``
writes (:mod:`eval.baseline`). Read at call time by the admin panel's
feedback-loop stats endpoint (``docs/admin-panel-architecture.md`` §3's
"closing the loop visibly" -- phase 4 spec §5) to show the golden set's
most recently recorded accuracy alongside its size and flag volume,
without re-running the harness against a live endpoint on every panel
load. A missing file (no baseline has been recorded yet) degrades to
"no baseline recorded" rather than an error -- this setting exists so
the panel can find the file without a request needing to name a
filesystem path, the same reasoning as ``eval_golden_path`` above."""
config_version_cache_ttl_seconds: float = field(
default_factory=lambda: float(
os.getenv("CONFIG_VERSION_CACHE_TTL_SECONDS", "5")
)
)
"""How long :mod:`appdb.config_versions` may reuse a cached active
version id before re-reading it, mirroring ``key_cache_ttl_seconds``
and existing for the same reason: the id is on the per-request path
(``api/runner.py`` folds it into every query-cache key, so an applied
configuration version invalidates stale answers), and reading it from
the application database on every question would be a network round
trip per request on an external backend.
Applying a version invalidates this cache explicitly, so a change is
visible immediately in the process that made it; the TTL is what bounds
staleness in *other* processes, where the only cost of being late is a
few cache hits that should have been misses. Set to ``0`` to disable
the cache and read the id every time."""
config_export_dir: str = field(
default_factory=lambda: os.getenv("CONFIG_EXPORT_DIR", "")
)
"""Directory :mod:`appdb.config_versions` writes the ``project_config/``
YAML bundle to on every applied version (spec §7) -- offline inspection
with familiar tools, and an off-box backup, without this system's
correctness depending on git being installed. Empty (the default)
disables the export write entirely; a deployment that wants it points
this at a directory of its own -- optionally one under version control
with its own remote (``docs/admin-panel-architecture.md`` §6.3: "git as
an output, not as the engine" -- never this project's own repository)."""
# ── Admin panel, phase 5: migration between backends ────────────────────
migration_quiet_window_seconds: float = field(
default_factory=lambda: float(
os.getenv("MIGRATION_QUIET_WINDOW_SECONDS", "60")
)
)
"""How recent a write to the application database may be before
:mod:`appdb.migrate` refuses to run (``docs/admin-panel-architecture.md``
§5.4/§5.4.1, §3 tier 3's not-yet-built maintenance mode). The tool scans
every migrated table's own timestamp columns (``created_at``,
``updated_at``, ...) for the most recent one and compares its age
against this window; anything younger means the application was still
writing a moment ago and the copy this tool is about to take may not
include everything, with no error at the time it is lost (§7).
``60`` is a deliberately generous default: this check exists to catch
"the operator forgot to stop the application", not to shave seconds off
a maintenance window, and a false refusal here costs an operator one
re-run a minute later while a false pass risks silently dropped writes.
A deployment confident in its own shutdown discipline may lower it; one
whose application takes longer than a minute to fully quiesce should
raise it instead of routinely overriding the refusal by other means."""
# ── Admin panel, phase 6: the operational tier ───────────────────────────
maintenance_drain_deadline_seconds: int = field(
default_factory=lambda: int(os.getenv("MAINTENANCE_DRAIN_DEADLINE_SECONDS", "120"))
)
"""How long (seconds) an operator should expect in-flight requests to
take to drain after maintenance mode is switched on (``api.maintenance``).
Purely informational: this codebase has no mechanism that forcibly
cancels a request already past its admission check (see
``api.maintenance.require_not_in_maintenance``'s docstring for why
"drain" falls out of *where* the check runs, not from any cancellation
logic) — killing a query mid-execution buys nothing and loses the
analyst their answer, per ``docs/admin-panel-architecture.md``'s
maintenance-mode definition. This value is returned alongside the
toggle-on response so an operator has a concrete number to watch
rather than guessing: if every in-flight request has not finished by
the time this many seconds have passed, treat that as a hang worth
investigating (a stuck LLM call, a wedged database connection), not as
evidence the switch failed. ``120`` is deliberately generous — comfortably
above :attr:`query_timeout_seconds` plus the self-correction rounds a
single query may spend."""
admin_action_log_retention_days: int = field(
default_factory=lambda: int(os.getenv("ADMIN_ACTION_LOG_RETENTION_DAYS", "365"))
)
"""How many days of ``admin_action_log.jsonl`` history
(:mod:`appdb.admin_audit`) are kept before :func:`appdb.admin_audit.purge_expired_admin_actions`
discards a record permanently. ``<= 0`` disables the purge entirely —
keep everything forever, the safest choice for a compliance-sensitive
deployment that would rather manage disk space by hand than lose
evidence automatically.
This log is exempt from the size-based rotation every other JSONL log
in this project uses (:attr:`log_max_bytes`/:attr:`log_backup_count`)
precisely so this setting is the ONLY thing that can make an admin
action disappear. Size-based rotation discards the *oldest* evidence
first, exactly when there is the *most* activity — which means anyone
wanting to bury a specific admin action could do so on purpose by
generating enough unrelated admin noise to roll it off the end of the
file before anyone reads it. A trail whose whole purpose is "each
admin role can read that the other one acted"
(``docs/admin-panel-architecture.md`` §2.4) cannot depend on a
retention mechanism an interested party can defeat by volume. Retention
by time closes that path: a record is discarded only once it is
genuinely older than this many days, never because something noisier
was appended after it. An on-prem deployment with an externally
imposed retention requirement sets this explicitly rather than
accepting the default; see ``docs/deployment-runbook.md``."""
# ── Admin panel, phase 6: a small, separate bucket for auth FAILURES ────
# See docs/admin-panel-architecture.md §9's resolved "is IP alone
# enough for failed admin auth?" question, and
# api.middleware.RateLimitMiddleware's own docstring for how this
# bucket is kept separate from the shared, much larger
# rate_limit_requests bucket every other unauthenticated/authenticated
# request draws from.
auth_failure_rate_limit_requests: int = field(
default_factory=lambda: int(os.getenv("AUTH_FAILURE_RATE_LIMIT_REQUESTS", "20"))
)
"""Sustained allowance, per window, for requests that presented an
``Authorization`` header that failed to resolve to a principal at all
(a wrong/expired/typo'd key — never a *missing* header, which is
ordinary unauthenticated traffic and keeps using
:attr:`rate_limit_requests`'s much larger shared bucket).
Deliberately small and deliberately separate. Guessing a real key is
arithmetically impossible (``security.auth``'s keys are
``secrets.token_urlsafe(32)`` — 256 bits of entropy), so this is not
a brute-force defence; tightening it further would be security
theatre against a threat that does not exist. The actual problem this
solves: ``api.auth.AuthMiddleware`` runs before the rate limiter, so a
single misbehaving client looping on a stale/rotated key would
otherwise consume tokens from the *same* IP-keyed bucket that
genuinely unauthenticated traffic from that address (a monitoring
probe behind the same reverse proxy, most concretely) also draws
from — starving the probe and making the resulting 429 look exactly
like an outage. A small, separate ceiling for auth failures protects
that shared budget without touching the limit that actually matters
for real traffic."""
auth_failure_rate_limit_window_seconds: float = field(
default_factory=lambda: float(os.getenv("AUTH_FAILURE_RATE_LIMIT_WINDOW_SEC", "60"))
)
"""Window (seconds) :attr:`auth_failure_rate_limit_requests` refills
over — same unit as :attr:`rate_limit_window_seconds`, kept as a
separate setting so the two buckets' windows can be tuned
independently."""
auth_failure_rate_limit_burst: int = field(
default_factory=lambda: int(os.getenv("AUTH_FAILURE_RATE_LIMIT_BURST", "5"))
)
"""Extra tokens above :attr:`auth_failure_rate_limit_requests` a single
auth-failure bucket may spend instantly — mirrors
:attr:`rate_limit_burst`'s role for the shared bucket, sized much
smaller to match."""
def validate(self) -> None:
"""Raise ValueError if any required setting is missing or still a placeholder.
A ``.env`` line python-dotenv could not use (unparsable, a leftover
line of a broken multi-line value, or a variable assigned twice with
different values) is refused first, listing every such line by number
and variable name -- never by value -- because the settings read below
may be wrong for exactly that reason. See :mod:`core.dotenv_check`.
Every warehouse connection string is checked by
:func:`_check_warehouse_url`: ``db_connection_url`` (with
``db_password`` applied) when there is no ``datasources.yaml``,
otherwise each data source's connection (see
:mod:`database.datasources`). A structured source whose
``username_env`` / ``password_env`` variable is unset or empty is
refused, naming the source and the variable. So is a ``url_env``
variable (or ``DB_CONNECTION_URL``) that is unset or empty. Each of
these adds why the variable is missing
(:func:`explain_unset_variable`); a connection string that is set but
wrong does not.
"""
if _dotenv_problems:
from core.dotenv_check import format_problems
raise ValueError(format_problems(_dotenv_path, _dotenv_problems))
placeholders = {
"your_password_here", "your_server_here",
"your_db_here", "change_me", "",
}
if not self.openai_model or self.openai_model in placeholders:
raise ValueError("OPENAI_MODEL is not configured")
# Fail closed for an unconfigured/unsupported SQL_DIALECT -- an
# unknown dialect, or one whose profile has no system-catalogue
# blocklist, must never reach request-serving code (see
# security.dialects.require_dialect_supported's docstring for why
# an empty blocklist is refused rather than treated as "nothing to
# block"). Deferred import: security.dialects has no dependency on
# config, but importing it at module scope here would still make
# every `import config` pay for importing security.dialects even
# when nothing ever calls validate() -- matches this module's own
# "read at call time" convention for cross-module dependencies.
from security.dialects import require_dialect_supported
require_dialect_supported(self.sql_dialect)
# Every warehouse connection string gets the same checks: the
# single DB_CONNECTION_URL when there is no datasources.yaml, or
# each source's connection when there is one (in which case
# DB_CONNECTION_URL itself is not used and not required). See
# database.datasources.
from database.datasources import UnsetVariableError, validate_datasource_urls
try:
validate_datasource_urls(
lambda label, url, dialect: _check_warehouse_url(label, url, dialect, placeholders),
self,
placeholders,
)
except UnsetVariableError as exc:
# "not set or is empty" alone points at the wrong place when the
# variable *is* written in a .env that was never loaded.
hint = "".join(f"\n {line}" for line in explain_unset_variable(exc.variable))
raise UnsetVariableError(f"{exc}{hint}", exc.variable) from exc
# LLM_EXTRA_BODY is parsed on every request, so a malformed value
# would otherwise surface as an error on the first question rather
# than at start-up -- and the operator most likely to set it is one
# who just watched a reasoning model return nothing and is trying to
# turn that off. Failing here means a typo is caught by the same
# pre-flight run (scripts/verify_deployment.py) that already proves
# the rest of the configuration, instead of by an analyst.
# Deferred import for the same reason as the two above: llm.providers
# imports this module.
from llm.providers import load_extra_body
load_extra_body()
if self.llm_prefix_warmup_timeout_seconds <= 0:
raise ValueError(
"LLM_PREFIX_WARMUP_TIMEOUT_SECONDS must be greater than zero "
f"(got {self.llm_prefix_warmup_timeout_seconds}); set "
"LLM_PREFIX_WARMUP_ON_STARTUP=false to turn the warm-up off instead."
)
keepwarm = self.llm_prefix_keepwarm_seconds
if keepwarm != keepwarm or keepwarm in (float("inf"), float("-inf")) or (
keepwarm != 0 and keepwarm < 30
):
raise ValueError(
"LLM_PREFIX_KEEPWARM_SECONDS must be 0 (off) or at least 30 "
f"(got {keepwarm}); a shorter interval would send the model "
"server a request every few seconds for no benefit."
)
if self.llm_cache_suspect_ms < 0:
raise ValueError(
"LLM_CACHE_SUSPECT_MS must be 0 (off) or a number of milliseconds "
f"(got {self.llm_cache_suspect_ms})."
)
@lru_cache(maxsize=1)
def get_settings() -> Settings:
"""Return the singleton Settings instance (cached after first call)."""
return Settings()
# Module-level singleton — ALL modules must access settings as ``cfg.settings``
# (i.e. ``import config as cfg``) so that override_settings() patches are
# visible at call-time rather than being captured at import-time.
settings: Settings = get_settings()
@contextmanager
def override_settings(**kwargs: Any) -> Generator[Settings, None, None]:
"""Context manager for tests: temporarily replace ``cfg.settings``.
Because all consumers read ``cfg.settings`` lazily (not via a local
``from config import settings`` binding), every module sees the new
value for the lifetime of the ``with`` block.
The original singleton is restored on exit, even on exception.
Usage::
import config as cfg
from config import override_settings
with override_settings(max_rows_returned=5) as s:
assert s.max_rows_returned == 5
assert cfg.settings.max_rows_returned == 5
"""
import config as _cfg # always the real module object
original = _cfg.settings
patched = Settings(**{
**{f: getattr(original, f) for f in original.__slots__},
**kwargs,
})
_cfg.settings = patched
try:
yield patched
finally:
_cfg.settings = original