Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions backend/app/llm/budget_gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@

from datetime import UTC, datetime

import structlog
from redis.asyncio import Redis

_TTL_SECONDS = 26 * 60 * 60
Expand Down Expand Up @@ -90,3 +91,40 @@ async def record_cost(
total = await redis.incrbyfloat(key, cost_usd)
await redis.expire(key, _TTL_SECONDS)
return float(total)


async def safe_record_cost(
redis: Redis,
cost_usd: float,
*,
logger: structlog.stdlib.BoundLogger,
log_message: str,
event_type: str,
) -> float | None:
"""Record an LLM cost, swallowing transient Redis failures.

Per GPT-5.5 cycle-2 C2-F3 (feat_llm_judgments): a Redis hiccup AFTER a
paid LLM call must not propagate up and abort the caller — the caller
persists its artifacts (judgments, digest) BEFORE calling this, so
under-counting daily spend during a Redis outage is recoverable on
rollover while losing the paid-for output is not. Returns ``None`` on
failure.

Lives here (next to :func:`record_cost`) rather than in the worker layer
so the judgment-generation service can record cost without importing up
into ``backend.workers``. ``log_message`` / ``event_type`` are passed by
the caller so each caller keeps its own log voice (``judgment worker: …``
/ ``judgment_record_cost_failed`` etc.) while sharing the one defensive
contract.
"""
try:
return await record_cost(redis, cost_usd)
except Exception as exc: # noqa: BLE001 — defensive
logger.warning(
log_message,
event_type=event_type,
cost_usd=cost_usd,
error_type=type(exc).__name__,
error=str(exc),
)
return None
Loading
Loading