From 99a0ad0b36d9215e1d579f7449721f8371bf97e8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 17 Aug 2026 09:51:10 +0000 Subject: [PATCH 1/2] Add LLM timeouts, retries, fallback, and circuit breaker Wire LLM_TIMEOUT, LLM_MAX_RETRIES, LLM_MAX_TOKENS, and breaker settings through get_settings so every provider call has a bounded timeout and jittered retries. On failure, explain/ask fall back to deterministic templates with llm.fell_back; an open breaker is surfaced on GET /health without failing probes. Closes #19 Co-authored-by: Leonardo --- .env.example | 10 + README.md | 12 +- src/api/routes/health.py | 26 +- src/config/settings.py | 10 + src/core/explain/summarizer.py | 8 +- src/core/llm/provider.py | 97 +++++-- src/core/llm/resilience.py | 257 +++++++++++++++++ src/core/retrieval/question_router.py | 57 ++-- tests/unit/test_api.py | 36 +++ tests/unit/test_llm_concurrency.py | 3 + tests/unit/test_llm_resilience.py | 399 ++++++++++++++++++++++++++ tests/unit/test_settings.py | 24 ++ 12 files changed, 874 insertions(+), 65 deletions(-) create mode 100644 src/core/llm/resilience.py create mode 100644 tests/unit/test_llm_resilience.py diff --git a/.env.example b/.env.example index 052ccd3..5b26663 100644 --- a/.env.example +++ b/.env.example @@ -112,6 +112,16 @@ INGEST_RETRY_AFTER_SECONDS=5 # Max in-flight LLM provider calls (process-wide; independent of API concurrency). # 0 = unlimited. Noop provider skips the wait. # LLM_MAX_CONCURRENCY=4 + +# LLM resilience (timeout, retries, token ceiling, circuit breaker). Unused when +# LLM_PROVIDER=disabled (noop). On timeout/error/open breaker, explain/ask fall +# back to deterministic templates and set llm.fell_back=true; the request succeeds. +# LLM_TIMEOUT=30 +# LLM_MAX_RETRIES=2 # extra attempts after the first (3 total) +# LLM_MAX_TOKENS=600 # completion cap (OpenAI max_tokens / Ollama num_predict) +# LLM_MAX_INPUT_TOKENS=0 # 0 = derive from LLM_MAX_TOKENS (chars/4 estimate) +# LLM_BREAKER_THRESHOLD=5 # consecutive failures before the breaker opens +# LLM_BREAKER_COOLDOWN_SECONDS=60 # Max NDJSON lines accepted by POST /v1/ingestions/lines (over cap → 400). INGEST_PUSH_MAX_LINES=5000 # Tail-mode poll cadence and consecutive-error auto-pause. diff --git a/README.md b/README.md index 1296591..54c13b9 100644 --- a/README.md +++ b/README.md @@ -696,6 +696,12 @@ All settings are read from `.env`, environment variables, or CLI flags. Priority | `INGEST_QUEUE_MAX` | `100` | Pending worker-job ceiling; over this, ingest returns `429 INGEST_QUEUE_FULL` | | `INGEST_RETRY_AFTER_SECONDS` | `5` | `Retry-After` value on `429 INGEST_QUEUE_FULL` | | `LLM_MAX_CONCURRENCY` | `4` | Max in-flight LLM provider calls process-wide. `0` = unlimited. Noop does not wait | +| `LLM_TIMEOUT` | `30` | Per-attempt HTTP timeout in seconds for OpenAI/Ollama calls | +| `LLM_MAX_RETRIES` | `2` | Extra attempts after the first (3 total) with jittered exponential backoff | +| `LLM_MAX_TOKENS` | `600` | Completion cap (`max_tokens` / Ollama `num_predict`) | +| `LLM_MAX_INPUT_TOKENS` | `0` | Estimated input-token budget (`chars/4`). `0` derives from `LLM_MAX_TOKENS`. Over budget: trim evidence (respecting `MAX_EVIDENCE_ITEMS`) or fall back | +| `LLM_BREAKER_THRESHOLD` | `5` | Consecutive LLM failures before the process-local breaker opens | +| `LLM_BREAKER_COOLDOWN_SECONDS` | `60` | Seconds the breaker stays open before a half-open probe | | `WEBHOOK_SECRET` | _(empty)_ | Fallback HMAC secret for ingest completion callbacks when auth is off or the API key has no per-key `whsec_` | | `WEBHOOK_MAX_RETRIES` | `5` | Extra webhook POST attempts after the first (6 POSTs by default) on 5xx / 429 / connect errors | | `WEBHOOK_TIMEOUT` | `10` | Per-attempt HTTP timeout in seconds for completion callbacks | @@ -709,6 +715,8 @@ raglogs is fully useful without any LLM. The `--no-llm` flag (or `LLM_PROVIDER=d When an LLM is configured, it receives only a small curated evidence packet — not raw logs. The prompt enforces fixed output structure, prohibits fabrication, and requires explicit uncertainty statements when evidence is insufficient. In-flight provider calls are capped by `LLM_MAX_CONCURRENCY` (CLI and API share the process semaphore; the noop provider does not block). +Every provider call has a timeout (`LLM_TIMEOUT`) and bounded jittered retries (`LLM_MAX_RETRIES`). On timeout, HTTP error, exhausted retries, or an over-budget evidence payload, explain/ask **fall back to the same deterministic templates** and set `llm.fell_back=true` — the request still succeeds. After `LLM_BREAKER_THRESHOLD` consecutive failures a process-local circuit breaker opens for `LLM_BREAKER_COOLDOWN_SECONDS`; while open, raglogs skips the provider entirely and serves templates. `GET /health` exposes `llm_breaker: {state, consecutive_failures, cooldown_remaining_seconds}` (`closed` / `open` / `half_open`). An open breaker marks `status` as `degraded` but still returns HTTP 200 so probes do not fail. Fallback never invents: it only renders the curated evidence packet. + ### OpenAI ```env @@ -970,7 +978,7 @@ If auth is disabled and the process binds a non-loopback address (`0.0.0.0`, `:: | Method | Endpoint | Description | |---|---|---| -| `GET` | `/health` | Service and DB health check (unversioned). Includes `tail_jobs: {running, paused}`. | +| `GET` | `/health` | Service and DB health check (unversioned). Includes `tail_jobs: {running, paused}` and `llm_breaker` (`closed` / `open` / `half_open`). Open breaker → `status: degraded`, still HTTP 200. | | `POST` | `/v1/ingestions` | Enqueue a batch ingest job (`adapter`: `file`, `cloudwatch`, `datadog`, `loki`, or `k8s`). Set `"mode": "tail"` for pull adapters to start a long-lived tail job. | | `POST` | `/v1/ingestions/lines` | Push NDJSON of raw or pre-parsed log lines (sync persist) | | `POST` | `/v1/ingestions/{id}:pause` | Pause a tail job | @@ -1014,7 +1022,7 @@ curl -X POST http://localhost:8000/v1/ingestions/$ID:stop - **Queue depth.** When pending worker jobs ≥ `INGEST_QUEUE_MAX` (default 100), `POST /v1/ingestions` and `POST /v1/ingestions/lines` return **429** with `Retry-After` (`INGEST_RETRY_AFTER_SECONDS`, default 5) and body `{"error_code":"INGEST_QUEUE_FULL","message":"..."}`. Tail ticks skip the same ceiling. - **API token bucket.** `POST /v1/ingestions*` (writes) and `/v1/query*` (plus unversioned aliases) are limited per API key (`request.state.auth_principal.key_id`, or a single `anonymous` bucket when `AUTH_ENABLED=false`). Exceeding the bucket returns **429** with `Retry-After` (`RATELIMIT_RETRY_AFTER_SECONDS`, default 1) and body `{"error_code":"RATE_LIMITED","message":"..."}`. Defaults (`RATELIMIT_INGEST_RPS` / `RATELIMIT_QUERY_RPS` / `RATELIMIT_BURST` = 100) are high enough for local demo and tests; `0` rps means unlimited for that category. `/health`, `/docs`, static UI, and `/config` are not limited. Buckets are in-memory per process. -LLM calls are separately capped by `LLM_MAX_CONCURRENCY` (default 4) so a burst of `explain` cannot fan out unbounded provider requests. +LLM calls are separately capped by `LLM_MAX_CONCURRENCY` (default 4) so a burst of `explain` cannot fan out unbounded provider requests. Timeouts, retries, automatic template fallback (`llm.fell_back`), and the process-local circuit breaker are described under [LLM integration](#llm-integration). **Idempotency-Key.** `POST /v1/ingestions` (batch enqueue and tail create; also the deprecated `/ingestions` alias) honors an `Idempotency-Key` header (max 256 characters). A repeat **in the same isolation scope** within `INGEST_IDEMPOTENCY_TTL_SECONDS` (default 86400) returns the original **202** job — the same `worker_job_id` for batch, the same `ingestion_job_id` for tail — instead of starting a new one. Reusing another scope's key returns **409** `IDEMPOTENCY_SCOPE_CONFLICT`. Empty keys return **400**. GET routes ignore the header. `POST /v1/ingestions/lines` does not use the header; duplicate push/tail lines are handled by content dedup instead. diff --git a/src/api/routes/health.py b/src/api/routes/health.py index 45fe0f1..c8739f0 100644 --- a/src/api/routes/health.py +++ b/src/api/routes/health.py @@ -12,12 +12,19 @@ _adapter_health_cache: dict[str, tuple[float, str]] = {} +class LlmBreakerHealth(BaseModel): + state: str # closed | open | half_open + consecutive_failures: int + cooldown_remaining_seconds: float + + class HealthResponse(BaseModel): status: str # ok | degraded db: str # connected | disconnected worker_queue_depth: Optional[int] # pending worker jobs; None if DB unreachable adapters: dict[str, str] # adapter name -> "ok" | "unavailable: " tail_jobs: Optional[dict[str, int]] = None # {running, paused}; None if DB unreachable + llm_breaker: Optional[LlmBreakerHealth] = None def _adapter_health() -> dict[str, str]: @@ -52,12 +59,25 @@ def _adapter_health() -> dict[str, str]: return statuses +def _llm_breaker_health() -> LlmBreakerHealth: + from src.core.llm.resilience import breaker_health + + snap = breaker_health() + return LlmBreakerHealth( + state=str(snap["state"]), + consecutive_failures=int(snap["consecutive_failures"]), + cooldown_remaining_seconds=float(snap["cooldown_remaining_seconds"]), + ) + + @router.get("/health", response_model=HealthResponse) -def health_check(): +def health_check() -> HealthResponse: from src.db.session import check_connection, get_db db_ok = check_connection() adapters = _adapter_health() + llm_breaker = _llm_breaker_health() + breaker_open = llm_breaker.state == "open" if not db_ok: return HealthResponse( @@ -66,6 +86,7 @@ def health_check(): worker_queue_depth=None, adapters=adapters, tail_jobs=None, + llm_breaker=llm_breaker, ) try: @@ -83,9 +104,10 @@ def health_check(): tail_jobs = None return HealthResponse( - status="ok" if db_ok else "degraded", + status="degraded" if breaker_open else "ok", db="connected", worker_queue_depth=depth, adapters=adapters, tail_jobs=tail_jobs, + llm_breaker=llm_breaker, ) diff --git a/src/config/settings.py b/src/config/settings.py index f778934..0e14166 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -72,6 +72,16 @@ class Settings(BaseSettings): # API concurrency. 0 = unlimited. Noop provider skips the wait. llm_max_concurrency: int = 4 + # LLM resilience (G10). Timeouts/retries wrap every provider HTTP call. + # On failure the pipeline falls back to deterministic templates. + # 0 input-token budget means "derive from LLM_MAX_TOKENS". + llm_timeout: float = 30.0 + llm_max_retries: int = 2 + llm_max_tokens: int = 600 + llm_max_input_tokens: int = 0 + llm_breaker_threshold: int = 5 + llm_breaker_cooldown_seconds: float = 60.0 + # HMAC-signed ingest completion webhooks (G5). WEBHOOK_SECRET is the # fallback when AUTH_ENABLED=false or the API key has no per-key secret. webhook_secret: str = "" diff --git a/src/core/explain/summarizer.py b/src/core/explain/summarizer.py index bd12c55..a3c2f6c 100644 --- a/src/core/explain/summarizer.py +++ b/src/core/explain/summarizer.py @@ -3,6 +3,7 @@ from datetime import datetime from typing import Optional +import structlog from sqlalchemy.orm import Session from src.config import get_settings @@ -14,6 +15,8 @@ from src.db.models import DEFAULT_LOG_SCOPE, IngestionJob from src.db.scope_filter import filter_ingestion_jobs_by_scope +log = structlog.get_logger() + @dataclass class ExplainResult: @@ -122,8 +125,9 @@ def explain_window( summary_text = llm_text mode = "llm" except Exception: - # Degrade gracefully - pass + # Timeout, retries exhausted, open breaker, or budget: keep mode + # "rules" so llm.fell_back is true when an LLM was requested. + log.warning("llm_explain_failed", exc_info=True) if not summary_text: summary_text = render_text_summary(packet, confidence) diff --git a/src/core/llm/provider.py b/src/core/llm/provider.py index ca5a327..c6cfc9e 100644 --- a/src/core/llm/provider.py +++ b/src/core/llm/provider.py @@ -5,7 +5,8 @@ from typing import Any, Protocol, runtime_checkable import httpx -from tenacity import retry, stop_after_attempt, wait_exponential + +from src.core.llm.resilience import invoke_llm, prepare_llm_packet @runtime_checkable @@ -39,6 +40,18 @@ def generate_summary(self, evidence_packet: dict) -> str: If evidence is insufficient, say so clearly. Keep the entire output under 300 words. No markdown formatting.""" +def _llm_timeout() -> float: + from src.config import get_settings + + return float(get_settings().llm_timeout) + + +def _llm_max_tokens() -> int: + from src.config import get_settings + + return int(get_settings().llm_max_tokens) + + class OpenAILLMProvider: def __init__( self, @@ -50,12 +63,9 @@ def __init__( self.model = model self.base_url = base_url.rstrip("/") - @retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=10)) - def generate_summary(self, evidence_packet: dict) -> str: - payload = json.dumps(evidence_packet, default=str, indent=2) - user_message = f"Analyze this incident evidence and produce a summary:\n\n{payload}" - - with httpx.Client(timeout=60) as client: + def complete(self, system_prompt: str, user_message: str) -> str: + """Single OpenAI chat-completions attempt (timeout/max_tokens from settings).""" + with httpx.Client(timeout=_llm_timeout()) as client: response = client.post( f"{self.base_url}/chat/completions", headers={ @@ -64,10 +74,10 @@ def generate_summary(self, evidence_packet: dict) -> str: }, json={ "model": self.model, - "max_tokens": 600, + "max_tokens": _llm_max_tokens(), "temperature": 0, "messages": [ - {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "system", "content": system_prompt}, {"role": "user", "content": user_message}, ], }, @@ -76,6 +86,11 @@ def generate_summary(self, evidence_packet: dict) -> str: data = response.json() return data["choices"][0]["message"]["content"].strip() + def generate_summary(self, evidence_packet: dict) -> str: + payload = json.dumps(evidence_packet, default=str, indent=2) + user_message = f"Analyze this incident evidence and produce a summary:\n\n{payload}" + return self.complete(SYSTEM_PROMPT, user_message) + class OllamaLLMProvider: def __init__( @@ -86,25 +101,55 @@ def __init__( self.model = model self.base_url = base_url.rstrip("/") - @retry(stop=stop_after_attempt(2), wait=wait_exponential(multiplier=1, min=1, max=5)) - def generate_summary(self, evidence_packet: dict) -> str: - payload = json.dumps(evidence_packet, default=str, indent=2) - prompt = f"{SYSTEM_PROMPT}\n\nIncident evidence:\n{payload}\n\nSummary:" - - with httpx.Client(timeout=120) as client: + def complete(self, system_prompt: str, user_message: str) -> str: + """Single Ollama /api/generate attempt (timeout/num_predict from settings).""" + prompt = f"{system_prompt}\n\n{user_message}" + with httpx.Client(timeout=_llm_timeout()) as client: response = client.post( f"{self.base_url}/api/generate", json={ "model": self.model, "prompt": prompt, "stream": False, - "options": {"temperature": 0}, + "options": { + "temperature": 0, + "num_predict": _llm_max_tokens(), + }, }, ) response.raise_for_status() data = response.json() return data.get("response", "").strip() + def generate_summary(self, evidence_packet: dict) -> str: + payload = json.dumps(evidence_packet, default=str, indent=2) + user_message = f"Incident evidence:\n{payload}\n\nSummary:" + return self.complete(SYSTEM_PROMPT, user_message) + + +class ResilientLLMProvider: + """G10 breaker + token budget + retries around an inner provider. + + Lives *inside* ``CappedLLMProvider`` so in-flight slots cover the whole + retry sequence, but the breaker check still skips HTTP when open. + """ + + def __init__(self, inner: LLMProvider) -> None: + self.inner = inner + + def generate_summary(self, evidence_packet: dict) -> str: + from src.config import get_settings + + prepared = prepare_llm_packet(evidence_packet, get_settings()) + return invoke_llm(lambda: self.inner.generate_summary(prepared)) + + def complete(self, system_prompt: str, user_message: str) -> str: + inner = self.inner + complete = getattr(inner, "complete", None) + if complete is None: + return "" + return invoke_llm(lambda: complete(system_prompt, user_message)) + _llm_sem_lock = threading.Lock() _llm_semaphore: threading.Semaphore | None = None @@ -153,21 +198,24 @@ class CappedLLMProvider: Noop inner providers skip the wait so deterministic mode never blocks, but still go through this entrypoint (CLI and API share the semaphore). + + Stack (outer → inner): CappedLLMProvider → ResilientLLMProvider → OpenAI/Ollama. + Noop skips ResilientLLMProvider entirely. """ def __init__(self, inner: LLMProvider) -> None: self.inner = inner def generate_summary(self, evidence_packet: dict) -> str: - skip = isinstance(self.inner, NoopLLMProvider) + skip = isinstance(unwrap_llm_provider(self), NoopLLMProvider) with llm_concurrency_slot(skip=skip): return self.inner.generate_summary(evidence_packet) def unwrap_llm_provider(provider: LLMProvider) -> LLMProvider: - """Return the inner provider if ``provider`` is concurrency-capped.""" + """Return the inner provider if ``provider`` is concurrency-capped or resilient.""" inner: LLMProvider = provider - while isinstance(inner, CappedLLMProvider): + while isinstance(inner, (CappedLLMProvider, ResilientLLMProvider)): inner = inner.inner return inner @@ -190,5 +238,12 @@ def _build_inner_llm_provider(settings: Any) -> LLMProvider: def build_llm_provider(settings: Any) -> LLMProvider: - """Factory: build the configured LLM provider (concurrency-capped).""" - return CappedLLMProvider(_build_inner_llm_provider(settings)) + """Factory: build the configured LLM provider (resilient + concurrency-capped). + + Order: CappedLLMProvider (G9) wraps ResilientLLMProvider (G10) wraps the + HTTP provider. Noop skips the resilience wrapper so disabled mode is unchanged. + """ + inner = _build_inner_llm_provider(settings) + if not isinstance(inner, NoopLLMProvider): + inner = ResilientLLMProvider(inner) + return CappedLLMProvider(inner) diff --git a/src/core/llm/resilience.py b/src/core/llm/resilience.py new file mode 100644 index 0000000..7aef9f9 --- /dev/null +++ b/src/core/llm/resilience.py @@ -0,0 +1,257 @@ +"""G10 LLM resilience: timeout/retry wrapper, token budget, circuit breaker. + +Call order (outer → inner), shared by ``generate_summary`` and ask HTTP: + + CappedLLMProvider # G9 process-wide in-flight cap + ResilientLLMProvider # this module: breaker → token budget → retries + OpenAI / Ollama # single HTTP attempt with LLM_TIMEOUT / LLM_MAX_TOKENS + +Noop is not wrapped, so ``LLM_PROVIDER=disabled`` never trips the breaker. +While the breaker is open the provider is not called (no failure increment). +A cooldown expiry moves the breaker to half-open; one probe is allowed. +""" + +from __future__ import annotations + +import json +import threading +import time +from collections.abc import Callable +from typing import Any, Literal + +import httpx +import structlog +from tenacity import ( + Retrying, + retry_if_exception, + stop_after_attempt, + wait_exponential_jitter, +) + +from src.config.settings import Settings + +log = structlog.get_logger() + +BreakerState = Literal["closed", "open", "half_open"] + +_LIST_KEYS: tuple[str, ...] = ( + "evidence", + "clusters", + "secondary_clusters", + "trigger_candidates", +) + + +class LLMCircuitOpen(Exception): + """Raised when the process-local LLM breaker is open (or a probe is in flight).""" + + +class LLMBudgetExceeded(Exception): + """Raised when the evidence payload still exceeds the input-token budget after trim.""" + + +class LLMCircuitBreaker: + """Process-local consecutive-failure breaker (same lifetime as the G9 semaphore).""" + + def __init__(self) -> None: + self._lock = threading.Lock() + self._failures: int = 0 + self._opened_at: float | None = None + self._probe_in_flight: bool = False + + def snapshot( + self, + *, + threshold: int, + cooldown_seconds: float, + ) -> dict[str, Any]: + with self._lock: + state, remaining = self._state_unlocked(cooldown_seconds) + return { + "state": state, + "consecutive_failures": self._failures, + "cooldown_remaining_seconds": remaining, + } + + def allow_request(self, *, threshold: int, cooldown_seconds: float) -> bool: + """Return True if the provider may be called. + + ``threshold`` is accepted for a stable call signature with ``snapshot`` / + ``record_failure``; open/closed is driven by ``_opened_at``. + """ + del threshold + with self._lock: + state, _remaining = self._state_unlocked(cooldown_seconds) + if state == "closed": + return True + if state == "open": + return False + if self._probe_in_flight: + return False + self._probe_in_flight = True + return True + + def record_success(self) -> None: + with self._lock: + self._failures = 0 + self._opened_at = None + self._probe_in_flight = False + + def record_failure(self, *, threshold: int, cooldown_seconds: float) -> None: + del cooldown_seconds + with self._lock: + self._probe_in_flight = False + self._failures += 1 + if threshold > 0 and self._failures >= threshold: + self._opened_at = time.monotonic() + + def reset(self) -> None: + with self._lock: + self._failures = 0 + self._opened_at = None + self._probe_in_flight = False + + def _state_unlocked(self, cooldown_seconds: float) -> tuple[BreakerState, float]: + if self._opened_at is None: + return "closed", 0.0 + elapsed = time.monotonic() - self._opened_at + remaining = round(max(0.0, cooldown_seconds - elapsed), 1) + if remaining > 0: + return "open", remaining + return "half_open", 0.0 + + +_breaker_lock = threading.Lock() +_breaker: LLMCircuitBreaker | None = None + + +def get_llm_breaker() -> LLMCircuitBreaker: + global _breaker + with _breaker_lock: + if _breaker is None: + _breaker = LLMCircuitBreaker() + return _breaker + + +def reset_llm_breaker() -> None: + """Drop consecutive-failure state so tests can start from closed.""" + get_llm_breaker().reset() + + +def breaker_health() -> dict[str, Any]: + """Snapshot for ``GET /health`` using current settings.""" + from src.config import get_settings + + settings = get_settings() + return get_llm_breaker().snapshot( + threshold=settings.llm_breaker_threshold, + cooldown_seconds=settings.llm_breaker_cooldown_seconds, + ) + + +def default_llm_wait() -> wait_exponential_jitter: + """Jittered exponential backoff between LLM HTTP attempts.""" + return wait_exponential_jitter(initial=0.5, max=8) + + +def estimate_tokens(payload: Any) -> int: + """Cheap token estimate: UTF-8 chars / 4 (min 1).""" + if isinstance(payload, str): + text = payload + else: + text = json.dumps(payload, default=str, indent=2) + return max(1, (len(text) + 3) // 4) + + +def input_token_budget(settings: Settings) -> int: + if settings.llm_max_input_tokens > 0: + return settings.llm_max_input_tokens + return max(512, int(settings.llm_max_tokens) * 8) + + +def trim_evidence_packet(packet: dict[str, Any], max_items: int) -> dict[str, Any]: + """Copy ``packet`` and cap list-valued evidence fields at ``max_items``.""" + trimmed = dict(packet) + cap = max(0, max_items) + for key in _LIST_KEYS: + value = trimmed.get(key) + if isinstance(value, list) and len(value) > cap: + trimmed[key] = value[:cap] + return trimmed + + +def prepare_llm_packet(packet: dict[str, Any], settings: Settings) -> dict[str, Any]: + """Trim evidence to ``max_evidence_items``, then shrink until under budget. + + Raises ``LLMBudgetExceeded`` if the payload is still too large (e.g. a + single huge primary cluster). Callers fall back to templates. + """ + trimmed = trim_evidence_packet(packet, settings.max_evidence_items) + budget = input_token_budget(settings) + if estimate_tokens(trimmed) <= budget: + return trimmed + + while estimate_tokens(trimmed) > budget: + shrunk = False + for key in _LIST_KEYS: + value = trimmed.get(key) + if isinstance(value, list) and len(value) > 0: + trimmed[key] = value[:-1] + shrunk = True + break + if not shrunk: + raise LLMBudgetExceeded( + f"evidence payload ~{estimate_tokens(trimmed)} tokens " + f"exceeds budget {budget}" + ) + return trimmed + + +def _is_retryable_llm_error(exc: BaseException) -> bool: + if isinstance(exc, (LLMCircuitOpen, LLMBudgetExceeded)): + return False + if isinstance(exc, httpx.HTTPStatusError): + code = exc.response.status_code + return code == 429 or code >= 500 + if isinstance(exc, httpx.HTTPError): + return True + return True + + +def invoke_llm(attempt: Callable[[], str]) -> str: + """Run ``attempt`` with breaker + bounded jittered retries. + + One exhausted retry sequence counts as a single consecutive failure. + ``attempt`` should perform a single HTTP call (timeout lives on httpx). + """ + from src.config import get_settings + + settings = get_settings() + breaker = get_llm_breaker() + threshold = settings.llm_breaker_threshold + cooldown = settings.llm_breaker_cooldown_seconds + + if not breaker.allow_request(threshold=threshold, cooldown_seconds=cooldown): + snap = breaker.snapshot(threshold=threshold, cooldown_seconds=cooldown) + log.warning("llm_circuit_open", **snap) + raise LLMCircuitOpen("LLM circuit breaker is open") + + result = "" + try: + for retry_state in Retrying( + stop=stop_after_attempt(max(1, int(settings.llm_max_retries) + 1)), + wait=default_llm_wait(), + retry=retry_if_exception(_is_retryable_llm_error), + reraise=True, + ): + with retry_state: + result = attempt() + except (LLMCircuitOpen, LLMBudgetExceeded): + raise + except Exception: + breaker.record_failure(threshold=threshold, cooldown_seconds=cooldown) + log.warning("llm_call_failed", exc_info=True) + raise + + breaker.record_success() + return result diff --git a/src/core/retrieval/question_router.py b/src/core/retrieval/question_router.py index 99466ae..4a6b294 100644 --- a/src/core/retrieval/question_router.py +++ b/src/core/retrieval/question_router.py @@ -323,7 +323,7 @@ def answer_question( if answer_text: mode = "llm" except Exception: - pass # degrade to rules + log.warning("llm_ask_failed", exc_info=True) if not answer_text: answer_text = _rules_answer(question, clusters, len(matching)) @@ -403,9 +403,13 @@ def _call_llm_ask(llm: object, question: str, evidence_packet: dict) -> str: Constructs the HTTP call directly rather than reusing generate_summary, which uses the incident-summary system prompt. Shares the G9 LLM concurrency semaphore with generate_summary. + + Order matches generate_summary: cap (slot) → breaker/retries (invoke_llm) + → single HTTP attempt on the unwrapped OpenAI/Ollama provider. """ import json - import httpx + + from src.config import get_settings from src.core.llm.provider import ( NoopLLMProvider, OpenAILLMProvider, @@ -413,46 +417,23 @@ def _call_llm_ask(llm: object, question: str, evidence_packet: dict) -> str: llm_concurrency_slot, unwrap_llm_provider, ) + from src.core.llm.resilience import invoke_llm, prepare_llm_packet - inner = unwrap_llm_provider(llm) - payload_str = json.dumps(evidence_packet, default=str, indent=2) + inner = unwrap_llm_provider(llm) # type: ignore[arg-type] + settings = get_settings() + prepared = prepare_llm_packet(evidence_packet, settings) + payload_str = json.dumps(prepared, default=str, indent=2) user_message = f"Question: {question}\n\nLog evidence:\n{payload_str}" - with llm_concurrency_slot(skip=isinstance(inner, NoopLLMProvider)): + def _attempt() -> str: if isinstance(inner, OpenAILLMProvider): - with httpx.Client(timeout=60) as client: - resp = client.post( - f"{inner.base_url}/chat/completions", - headers={ - "Authorization": f"Bearer {inner.api_key}", - "Content-Type": "application/json", - }, - json={ - "model": inner.model, - "max_tokens": 400, - "temperature": 0, - "messages": [ - {"role": "system", "content": ASK_SYSTEM_PROMPT}, - {"role": "user", "content": user_message}, - ], - }, - ) - resp.raise_for_status() - return resp.json()["choices"][0]["message"]["content"].strip() - + return inner.complete(ASK_SYSTEM_PROMPT, user_message) if isinstance(inner, OllamaLLMProvider): - prompt = f"{ASK_SYSTEM_PROMPT}\n\n{user_message}\n\nAnswer:" - with httpx.Client(timeout=120) as client: - resp = client.post( - f"{inner.base_url}/api/generate", - json={ - "model": inner.model, - "prompt": prompt, - "stream": False, - "options": {"temperature": 0}, - }, - ) - resp.raise_for_status() - return resp.json().get("response", "").strip() + return inner.complete(ASK_SYSTEM_PROMPT, f"{user_message}\n\nAnswer:") + return "" + + with llm_concurrency_slot(skip=isinstance(inner, NoopLLMProvider)): + if isinstance(inner, (OpenAILLMProvider, OllamaLLMProvider)): + return invoke_llm(_attempt) return "" diff --git a/tests/unit/test_api.py b/tests/unit/test_api.py index 3d32ecd..a2c2e6e 100644 --- a/tests/unit/test_api.py +++ b/tests/unit/test_api.py @@ -73,9 +73,13 @@ class TestHealth: @pytest.fixture(autouse=True) def _clear_adapter_health_cache(self): from src.api.routes.health import _adapter_health_cache + from src.core.llm.resilience import reset_llm_breaker + _adapter_health_cache.clear() + reset_llm_breaker() yield _adapter_health_cache.clear() + reset_llm_breaker() def test_health_ok_when_db_connected(self): with patch("src.db.session.check_connection", return_value=True), \ @@ -155,6 +159,38 @@ def test_health_reports_loki_unavailable_without_url(self): assert resp.json()["adapters"]["loki"].startswith("unavailable:") + def test_health_includes_llm_breaker_closed(self): + with patch("src.db.session.check_connection", return_value=True), \ + _patch_get_db(execute_scalar=3): + resp = client.get("/health") + + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "ok" + breaker = data["llm_breaker"] + assert breaker["state"] == "closed" + assert breaker["consecutive_failures"] == 0 + assert breaker["cooldown_remaining_seconds"] == 0 + + def test_health_degraded_when_breaker_open_db_still_connected(self): + from src.core.llm.resilience import get_llm_breaker + + breaker = get_llm_breaker() + breaker.record_failure(threshold=2, cooldown_seconds=60) + breaker.record_failure(threshold=2, cooldown_seconds=60) + + with patch("src.db.session.check_connection", return_value=True), \ + _patch_get_db(execute_scalar=3): + resp = client.get("/health") + + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "degraded" + assert data["db"] == "connected" + assert data["llm_breaker"]["state"] == "open" + assert data["llm_breaker"]["consecutive_failures"] == 2 + assert data["llm_breaker"]["cooldown_remaining_seconds"] > 0 + # ── POST /ingestions ────────────────────────────────────────────────────────── diff --git a/tests/unit/test_llm_concurrency.py b/tests/unit/test_llm_concurrency.py index 30186db..8cc329e 100644 --- a/tests/unit/test_llm_concurrency.py +++ b/tests/unit/test_llm_concurrency.py @@ -15,6 +15,7 @@ reset_llm_concurrency_limiter, unwrap_llm_provider, ) +from src.core.llm.resilience import reset_llm_breaker from src.core.retrieval.question_router import _call_llm_ask @@ -93,6 +94,7 @@ def generate_summary(self, evidence_packet: dict) -> str: def test_call_llm_ask_takes_concurrency_semaphore() -> None: """Ask HTTP must share the G9 slot; dropping it would allow overlapping posts.""" reset_llm_concurrency_limiter() + reset_llm_breaker() settings = Settings(_env_file=None, llm_max_concurrency=1) inner_current = 0 max_seen = 0 @@ -149,3 +151,4 @@ def _ask() -> str: assert max_seen == 1 reset_llm_concurrency_limiter() + reset_llm_breaker() diff --git a/tests/unit/test_llm_resilience.py b/tests/unit/test_llm_resilience.py new file mode 100644 index 0000000..d88b83a --- /dev/null +++ b/tests/unit/test_llm_resilience.py @@ -0,0 +1,399 @@ +"""Unit tests for G10 LLM resilience (no database).""" +from __future__ import annotations + +import uuid +from collections.abc import Iterator +from datetime import datetime, timezone +from typing import Any +from unittest.mock import MagicMock, patch + +import httpx +import pytest +from tenacity import wait_exponential_jitter, wait_none + +from src.config.settings import Settings +from src.core.clustering.clusterer import ClusterData +from src.core.explain.confidence import compute_confidence +from src.core.explain.evidence import EvidencePacket +from src.core.explain.summarizer import explain_window +from src.core.explain.templates import render_text_summary +from src.core.llm.provider import ( + CappedLLMProvider, + NoopLLMProvider, + OllamaLLMProvider, + OpenAILLMProvider, + ResilientLLMProvider, + build_llm_provider, + unwrap_llm_provider, +) +from src.core.llm.resilience import ( + LLMBudgetExceeded, + LLMCircuitOpen, + default_llm_wait, + estimate_tokens, + invoke_llm, + prepare_llm_packet, + reset_llm_breaker, + trim_evidence_packet, +) +from src.core.retrieval.question_router import _call_llm_ask, _rules_answer, answer_question +from src.db.models import LogEntry + +WINDOW_START = datetime(2026, 3, 12, 13, 0, tzinfo=timezone.utc) +WINDOW_END = datetime(2026, 3, 12, 14, 0, tzinfo=timezone.utc) + + +@pytest.fixture(autouse=True) +def _reset_breaker() -> Iterator[None]: + reset_llm_breaker() + yield + reset_llm_breaker() + + +def _settings(**kwargs: Any) -> Settings: + return Settings(_env_file=None, **kwargs) + + +def _cluster(message: str, count: int = 50) -> ClusterData: + return ClusterData( + fingerprint="abcd1234", + representative_message=message, + count=count, + services={"checkout": count}, + levels={"error": count}, + first_seen=WINDOW_START, + last_seen=WINDOW_END, + baseline_count=0, + change_ratio=51.0, + importance_score=8.0, + ) + + +def _packet(message: str = "payment gateway 502") -> EvidencePacket: + return EvidencePacket( + window_start=WINDOW_START, + window_end=WINDOW_END, + total_logs=404, + primary_cluster=_cluster(message), + secondary_clusters=[], + trigger_candidates=[], + evidence_items=[f"184 similar failures: {message}"], + services_affected=["checkout"], + ) + + +class _BoomProvider: + def __init__(self, exc: Exception | None = None) -> None: + self.calls = 0 + self.exc = exc or RuntimeError("provider down") + + def generate_summary(self, evidence_packet: dict) -> str: + self.calls += 1 + raise self.exc + + +class _FlakyProvider: + def __init__(self, fail_times: int) -> None: + self.calls = 0 + self.fail_times = fail_times + + def generate_summary(self, evidence_packet: dict) -> str: + self.calls += 1 + if self.calls <= self.fail_times: + raise RuntimeError("transient") + return "llm summary" + + +class _FakeResponse: + def __init__(self, payload: dict[str, Any]) -> None: + self._payload = payload + + def raise_for_status(self) -> None: + return None + + def json(self) -> dict[str, Any]: + return self._payload + + +class _CapturingClient: + last_timeout: float | None = None + last_json: dict[str, Any] | None = None + + def __init__(self, *args: object, timeout: float | None = None, **kwargs: object) -> None: + type(self).last_timeout = timeout + + def __enter__(self) -> _CapturingClient: + return self + + def __exit__(self, *args: object) -> bool: + return False + + def post(self, *args: object, **kwargs: object) -> _FakeResponse: + payload = kwargs.get("json") + if isinstance(payload, dict): + type(self).last_json = payload + return _FakeResponse({"choices": [{"message": {"content": "ok"}}]}) + + +class _TimeoutClient(_CapturingClient): + def post(self, *args: object, **kwargs: object) -> _FakeResponse: + raise httpx.TimeoutException("deadline exceeded") + + +def test_disabled_build_skips_resilient_wrapper() -> None: + llm = build_llm_provider(_settings(llm_provider="disabled")) + assert isinstance(llm, CappedLLMProvider) + assert isinstance(unwrap_llm_provider(llm), NoopLLMProvider) + assert not isinstance(llm.inner, ResilientLLMProvider) + assert llm.generate_summary({"x": 1}) == "" + + +def test_openai_build_wraps_resilient_inside_cap() -> None: + llm = build_llm_provider(_settings(llm_provider="openai", openai_api_key="sk-test")) + assert isinstance(llm, CappedLLMProvider) + assert isinstance(llm.inner, ResilientLLMProvider) + assert isinstance(unwrap_llm_provider(llm), OpenAILLMProvider) + + +def test_default_wait_is_exponential_jitter() -> None: + assert isinstance(default_llm_wait(), wait_exponential_jitter) + + +def test_retries_then_succeeds_without_opening_breaker() -> None: + inner = _FlakyProvider(fail_times=2) + settings = _settings(llm_max_retries=2, llm_breaker_threshold=5) + with patch("src.config.get_settings", return_value=settings), \ + patch("src.core.llm.resilience.default_llm_wait", return_value=wait_none()): + text = invoke_llm(lambda: inner.generate_summary({})) + assert text == "llm summary" + assert inner.calls == 3 + from src.core.llm.resilience import breaker_health + + with patch("src.config.get_settings", return_value=settings): + health = breaker_health() + assert health["state"] == "closed" + assert health["consecutive_failures"] == 0 + + +def test_exhausted_retries_count_as_one_failure() -> None: + inner = _BoomProvider() + settings = _settings(llm_max_retries=2, llm_breaker_threshold=5) + with patch("src.config.get_settings", return_value=settings), \ + patch("src.core.llm.resilience.default_llm_wait", return_value=wait_none()): + with pytest.raises(RuntimeError, match="provider down"): + invoke_llm(lambda: inner.generate_summary({})) + assert inner.calls == 3 + from src.core.llm.resilience import breaker_health + + with patch("src.config.get_settings", return_value=settings): + health = breaker_health() + assert health["state"] == "closed" + assert health["consecutive_failures"] == 1 + + +def test_breaker_opens_then_skips_provider() -> None: + inner = _BoomProvider() + settings = _settings(llm_max_retries=0, llm_breaker_threshold=2, llm_breaker_cooldown_seconds=60) + llm = ResilientLLMProvider(inner) + with patch("src.config.get_settings", return_value=settings), \ + patch("src.core.llm.resilience.default_llm_wait", return_value=wait_none()): + with pytest.raises(RuntimeError): + llm.generate_summary({"evidence": ["a"]}) + with pytest.raises(RuntimeError): + llm.generate_summary({"evidence": ["a"]}) + with pytest.raises(LLMCircuitOpen): + llm.generate_summary({"evidence": ["a"]}) + assert inner.calls == 2 + + +def test_breaker_success_resets() -> None: + calls = {"n": 0} + + def _attempt() -> str: + calls["n"] += 1 + if calls["n"] == 1: + raise RuntimeError("once") + return "ok" + + settings = _settings(llm_max_retries=0, llm_breaker_threshold=5) + with patch("src.config.get_settings", return_value=settings), \ + patch("src.core.llm.resilience.default_llm_wait", return_value=wait_none()): + with pytest.raises(RuntimeError): + invoke_llm(_attempt) + assert invoke_llm(_attempt) == "ok" + from src.core.llm.resilience import breaker_health + + with patch("src.config.get_settings", return_value=settings): + health = breaker_health() + assert health["state"] == "closed" + assert health["consecutive_failures"] == 0 + + +def test_half_open_probe_after_cooldown() -> None: + inner = _BoomProvider() + settings = _settings( + llm_max_retries=0, + llm_breaker_threshold=1, + llm_breaker_cooldown_seconds=0.0, + ) + llm = ResilientLLMProvider(inner) + with patch("src.config.get_settings", return_value=settings), \ + patch("src.core.llm.resilience.default_llm_wait", return_value=wait_none()): + with pytest.raises(RuntimeError): + llm.generate_summary({"evidence": ["a"]}) + # cooldown 0 → half-open; next call is a probe (provider called again) + with pytest.raises(RuntimeError): + llm.generate_summary({"evidence": ["a"]}) + assert inner.calls == 2 + + +def test_trim_respects_max_evidence_items() -> None: + settings = _settings(max_evidence_items=2, llm_max_input_tokens=100_000) + packet = { + "evidence": ["a", "b", "c", "d"], + "clusters": ["w", "x", "y"], + "primary_cluster": {"message": "payment gateway 502"}, + } + trimmed = trim_evidence_packet(packet, settings.max_evidence_items) + assert trimmed["evidence"] == ["a", "b"] + assert trimmed["clusters"] == ["w", "x"] + assert trimmed["primary_cluster"]["message"] == "payment gateway 502" + + +def test_prepare_falls_back_when_payload_still_over_budget() -> None: + settings = _settings(max_evidence_items=8, llm_max_input_tokens=8) + packet = {"primary_cluster": {"message": "x" * 5000}} + with pytest.raises(LLMBudgetExceeded): + prepare_llm_packet(packet, settings) + assert estimate_tokens(packet) > 8 + + +def test_openai_uses_configured_timeout_and_max_tokens() -> None: + _CapturingClient.last_timeout = None + _CapturingClient.last_json = None + settings = _settings(llm_timeout=12.0, llm_max_tokens=80) + provider = OpenAILLMProvider(api_key="sk-test") + with patch("src.config.get_settings", return_value=settings), \ + patch("httpx.Client", _CapturingClient): + assert provider.generate_summary({"a": 1}) == "ok" + assert _CapturingClient.last_timeout == 12.0 + assert _CapturingClient.last_json is not None + assert _CapturingClient.last_json["max_tokens"] == 80 + + +def test_ollama_uses_num_predict_from_settings() -> None: + _CapturingClient.last_json = None + + class _OllamaClient(_CapturingClient): + def post(self, *args: object, **kwargs: object) -> _FakeResponse: + payload = kwargs.get("json") + if isinstance(payload, dict): + type(self).last_json = payload + return _FakeResponse({"response": "local ok"}) + + settings = _settings(llm_timeout=9.0, llm_max_tokens=120) + provider = OllamaLLMProvider(model="llama3") + with patch("src.config.get_settings", return_value=settings), \ + patch("httpx.Client", _OllamaClient): + assert provider.generate_summary({"a": 1}) == "local ok" + assert _OllamaClient.last_json is not None + assert _OllamaClient.last_json["options"]["num_predict"] == 120 + + +def test_explain_timeout_falls_back_preserving_evidence() -> None: + packet = _packet("payment gateway 502") + settings = _settings(llm_provider="openai", openai_api_key="sk-test") + boom = _BoomProvider(exc=httpx.TimeoutException("deadline")) + + with patch("src.core.explain.summarizer.run_clustering", return_value=(None, [packet.primary_cluster])), \ + patch("src.core.explain.summarizer.assemble_evidence", return_value=packet), \ + patch("src.core.explain.summarizer.get_settings", return_value=settings), \ + patch("src.core.explain.summarizer.build_llm_provider", return_value=boom): + result = explain_window( + db=MagicMock(), + window_start=WINDOW_START, + window_end=WINDOW_END, + ) + + expected = render_text_summary(packet, compute_confidence(packet)) + assert result.mode == "rules" + assert result.summary_text == expected + assert "payment gateway 502" in result.summary_text + assert "184 similar failures: payment gateway 502" in result.summary_text + assert "invented root cause" not in result.summary_text.lower() + + +def test_explain_open_breaker_falls_back_without_calling_inner() -> None: + packet = _packet("connection refused by redis") + inner = _BoomProvider() + settings = _settings( + llm_provider="openai", + openai_api_key="sk-test", + llm_max_retries=0, + llm_breaker_threshold=1, + llm_breaker_cooldown_seconds=60, + ) + llm = CappedLLMProvider(ResilientLLMProvider(inner)) + + with patch("src.config.get_settings", return_value=settings), \ + patch("src.core.llm.resilience.default_llm_wait", return_value=wait_none()), \ + patch("src.core.explain.summarizer.run_clustering", return_value=(None, [packet.primary_cluster])), \ + patch("src.core.explain.summarizer.assemble_evidence", return_value=packet), \ + patch("src.core.explain.summarizer.get_settings", return_value=settings), \ + patch("src.core.explain.summarizer.build_llm_provider", return_value=llm): + first = explain_window(db=MagicMock(), window_start=WINDOW_START, window_end=WINDOW_END) + second = explain_window(db=MagicMock(), window_start=WINDOW_START, window_end=WINDOW_END) + + assert inner.calls == 1 + assert first.mode == "rules" + assert second.mode == "rules" + assert "connection refused by redis" in second.summary_text + assert second.summary_text == render_text_summary(packet, compute_confidence(packet)) + + +def test_ask_timeout_falls_back_to_rules_answer() -> None: + hit = LogEntry( + id=uuid.uuid4(), + timestamp=WINDOW_START, + service="checkout", + level="error", + raw_message="payment gateway 502 from checkout", + normalized_message="payment gateway 502 from checkout", + fingerprint="fp-pay-502", + ) + settings = _settings( + llm_provider="openai", + openai_api_key="sk-test", + llm_max_retries=0, + embeddings_provider="disabled", + ) + llm = OpenAILLMProvider(api_key="sk-test") + from src.core.embeddings.provider import DisabledEmbeddingsProvider + + with patch("src.config.get_settings", return_value=settings), \ + patch("src.core.retrieval.question_router.get_embeddings_provider", return_value=DisabledEmbeddingsProvider()), \ + patch("src.core.retrieval.question_router.search_logs", return_value=[hit]), \ + patch("src.core.llm.provider.build_llm_provider", return_value=llm), \ + patch("src.core.llm.resilience.default_llm_wait", return_value=wait_none()), \ + patch("httpx.Client", _TimeoutClient): + result = answer_question( + MagicMock(), + "why are payments failing?", + window_start=WINDOW_START, + window_end=WINDOW_END, + ) + + assert result.mode == "rules" + assert "payment gateway 502" in result.answer_text + expected = _rules_answer(result.question, result.clusters_used, result.total_matches) + assert result.answer_text == expected + + +def test_call_llm_ask_timeout_raises_after_retries() -> None: + settings = _settings(llm_max_retries=1, llm_timeout=1.0) + llm = OpenAILLMProvider(api_key="sk-test") + with patch("src.config.get_settings", return_value=settings), \ + patch("src.core.llm.resilience.default_llm_wait", return_value=wait_none()), \ + patch("httpx.Client", _TimeoutClient): + with pytest.raises(httpx.TimeoutException): + _call_llm_ask(llm, "why?", {"clusters": [{"message": "x"}]}) diff --git a/tests/unit/test_settings.py b/tests/unit/test_settings.py index edc727c..7aa6369 100644 --- a/tests/unit/test_settings.py +++ b/tests/unit/test_settings.py @@ -142,6 +142,12 @@ def test_ratelimit_and_llm_concurrency_defaults(): assert settings.ratelimit_retry_after_seconds == 1 assert settings.llm_max_concurrency == 4 assert settings.ingest_queue_max == 100 + assert settings.llm_timeout == 30.0 + assert settings.llm_max_retries == 2 + assert settings.llm_max_tokens == 600 + assert settings.llm_max_input_tokens == 0 + assert settings.llm_breaker_threshold == 5 + assert settings.llm_breaker_cooldown_seconds == 60.0 def test_ratelimit_and_llm_concurrency_from_env(monkeypatch): @@ -162,3 +168,21 @@ def test_ratelimit_and_llm_concurrency_from_env(monkeypatch): assert settings.ratelimit_retry_after_seconds == 9 assert settings.llm_max_concurrency == 1 assert settings.ingest_queue_max == 7 + + +def test_llm_resilience_settings_from_env(monkeypatch): + monkeypatch.setenv("LLM_TIMEOUT", "15") + monkeypatch.setenv("LLM_MAX_RETRIES", "4") + monkeypatch.setenv("LLM_MAX_TOKENS", "200") + monkeypatch.setenv("LLM_MAX_INPUT_TOKENS", "3000") + monkeypatch.setenv("LLM_BREAKER_THRESHOLD", "3") + monkeypatch.setenv("LLM_BREAKER_COOLDOWN_SECONDS", "45") + + settings = Settings(_env_file=None) + + assert settings.llm_timeout == pytest.approx(15.0) + assert settings.llm_max_retries == 4 + assert settings.llm_max_tokens == 200 + assert settings.llm_max_input_tokens == 3000 + assert settings.llm_breaker_threshold == 3 + assert settings.llm_breaker_cooldown_seconds == pytest.approx(45.0) From 87ff814dc60033cee9e5bcf50c63ff81697131e6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 17 Aug 2026 09:52:46 +0000 Subject: [PATCH 2/2] Remove unused imports in health API tests Ruff F401 failed on the touched health test module. Co-authored-by: Leonardo --- tests/unit/test_api.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/unit/test_api.py b/tests/unit/test_api.py index a2c2e6e..56ee953 100644 --- a/tests/unit/test_api.py +++ b/tests/unit/test_api.py @@ -11,8 +11,7 @@ import uuid import pytest from datetime import datetime, timezone -from contextlib import contextmanager -from unittest.mock import MagicMock, patch, call +from unittest.mock import MagicMock, patch from fastapi.testclient import TestClient from src.api.app import app