refactor(key budgets): give each budget caveat a code, a severity and its own row

`note` shipped as one prose string, so a client had to substring-match it to act
on anything, and two caveats arrived joined by "; " with no way to render them as
the list they were. Each caveat is now a `KeyBudgetNote` with a stable `code` to
branch on, an `info`/`warning` severity, and `text` that stays free to reword, and
`notes` is always a list rather than a nullable string.

Severity is the difference between a row that cannot trip, like a project budget
whose spend is never incremented, and one that needs reading, like a rolling window
or a scope the reservation layer already blocks at the limit.

The absence of a spend number was also being smuggled through the same field. It is
data quality, not a caveat, so it moved to `spend_state`: `live`, `no_counter` for a
per-model budget whose cache-only counter does not exist yet, and `unavailable` for
a read that failed. A failed read no longer renders as untouched headroom.

The endpoint has not shipped, so this costs nothing today and would be a breaking
change tomorrow.
This commit is contained in:
ryan-crabbe-berri 2026-08-19 17:46:50 -07:00
parent 1cbf830083
commit 9cbf871394
3 changed files with 221 additions and 78 deletions

View file

@ -67,8 +67,10 @@ from litellm.types.proxy.management_endpoints.key_management_endpoints import (
BudgetComparison,
BudgetEnforcement,
BudgetScope,
BudgetSpendState,
BudgetStatus,
KeyBudgetEntry,
KeyBudgetNote,
)
from litellm.types.utils import BudgetConfig
@ -94,32 +96,70 @@ _RESERVATION_COVERED_SCOPES: Final[frozenset[BudgetScope]] = frozenset(
{"key", "key_window", "team", "team_window", "team_member", "user", "organization", "tag", "end_user"}
)
_ALERT_ONLY_NOTE: Final = "alert only, never blocks; compared against recorded spend rather than the live counter"
_ROLLING_WINDOW_NOTE: Final = "rolling window; the start moves with reset_at so consecutive windows can overlap"
_MODEL_BUDGET_NOTE: Final = (
"per-model spend is counted separately for every request model that maps onto this cap, "
"and each counter is compared against it on its own, so the highest is reported"
_ALERT_ONLY_NOTE: Final = KeyBudgetNote(
code="alert_only",
severity="info",
text="alert only, never blocks; compared against recorded spend rather than the live counter",
)
_MODEL_BUDGET_COLD_NOTE: Final = (
"no per-model counter exists yet; these budgets are cache-only and fail open until one does"
_ROLLING_WINDOW_NOTE: Final = KeyBudgetNote(
code="rolling_window",
severity="warning",
text="rolling window; the start moves with reset_at so consecutive windows can overlap",
)
_RESERVATION_NOTE: Final = (
"the reservation layer blocks this scope as soon as spend reaches the limit, before the read-time check would trip"
_MODEL_BUDGET_NOTE: Final = KeyBudgetNote(
code="per_model_counters",
severity="warning",
text=(
"per-model spend is counted separately for every request model that maps onto this cap, "
"and each counter is compared against it on its own, so the highest is reported"
),
)
_PROJECT_SPEND_NOTE: Final = "project spend is never incremented today, so this budget cannot trip"
_TAG_NOTE: Final = "key tags are attached to every request; request-supplied tags add budgets not listed here"
_END_USER_ROUTE_NOTE: Final = "only enforced on LLM routes that name this end user"
_CUSTOM_AUTH_END_USER_NOTE: Final = (
"a custom auth callable can set a request-scoped end user cap that overrides this one and is not visible here"
_MODEL_BUDGET_COLD_NOTE: Final = KeyBudgetNote(
code="model_budget_fails_open",
severity="info",
text="no per-model counter exists yet; these budgets are cache-only and fail open until one does",
)
_USER_ON_TEAM_KEY_NOTE: Final = (
"the owner's personal budget is not applied to team keys unless "
"general_settings.apply_user_budget_to_team_keys is enabled"
_RESERVATION_NOTE: Final = KeyBudgetNote(
code="reservation_blocks_at_limit",
severity="warning",
text=(
"the reservation layer blocks this scope as soon as spend reaches the limit, "
"before the read-time check would trip"
),
)
_THROTTLE_NOTE: Final = (
"this key opted into throttle_on_budget_exceeded, so exceeding it slows requests instead of blocking"
_PROJECT_SPEND_NOTE: Final = KeyBudgetNote(
code="project_spend_not_tracked",
severity="info",
text="project spend is never incremented today, so this budget cannot trip",
)
_TAG_NOTE: Final = KeyBudgetNote(
code="request_tags_add_budgets",
severity="warning",
text="key tags are attached to every request; request-supplied tags add budgets not listed here",
)
_END_USER_ROUTE_NOTE: Final = KeyBudgetNote(
code="end_user_route_only",
severity="info",
text="only enforced on LLM routes that name this end user",
)
_CUSTOM_AUTH_END_USER_NOTE: Final = KeyBudgetNote(
code="custom_auth_may_override_end_user_cap",
severity="warning",
text="a custom auth callable can set a request-scoped end user cap that overrides this one and is not visible here",
)
_USER_ON_TEAM_KEY_NOTE: Final = KeyBudgetNote(
code="user_budget_not_applied_to_team_key",
severity="info",
text=(
"the owner's personal budget is not applied to team keys unless "
"general_settings.apply_user_budget_to_team_keys is enabled"
),
)
_THROTTLE_NOTE: Final = KeyBudgetNote(
code="throttled_instead_of_blocked",
severity="warning",
text="this key opted into throttle_on_budget_exceeded, so exceeding it slows requests instead of blocking",
)
_SPEND_UNREADABLE_NOTE: Final = "live spend could not be read"
class SpendReader(Protocol):
@ -270,6 +310,14 @@ class _EndUserModelSpend:
_SpendSource = _CounterSpend | _RecordedSpend | _KeyModelSpend | _EndUserModelSpend
@dataclass(frozen=True, slots=True)
class _SpendReading:
"""A spend number and why it is missing when it is, so a blank cell never reads as zero."""
value: float | None
state: BudgetSpendState
@dataclass(frozen=True, slots=True)
class _PlannedBudget:
scope: BudgetScope
@ -283,7 +331,7 @@ class _PlannedBudget:
budget_duration: str | None = None
budget_reset_at: datetime | None = None
window_start: datetime | None = None
note: str | None = None
notes: tuple[KeyBudgetNote, ...] = ()
class _MetadataTags(BaseModel):
@ -440,43 +488,55 @@ async def resolve_key_budgets(
token_end_user_max_budget=token_end_user_max_budget,
)
plans: Final = _plan_budgets(context)
spends: Final = await asyncio.gather(*(_read_spend(plan=plan, deps=deps) for plan in plans))
readings: Final = await asyncio.gather(*(_read_spend(plan=plan, deps=deps) for plan in plans))
reservation_enabled: Final = deps.general_settings.get("disable_budget_reservation") is not True
return tuple(
_to_entry(plan=plan, spend=spend, reservation_enabled=reservation_enabled)
for plan, spend in zip(plans, spends, strict=True)
_to_entry(plan=plan, reading=reading, reservation_enabled=reservation_enabled)
for plan, reading in zip(plans, readings, strict=True)
)
async def _read_spend(plan: _PlannedBudget, deps: KeyBudgetResolverDeps) -> float | None:
def _model_reading(spend: float | None) -> _SpendReading:
"""Per-model budgets are cache-only, so a missing counter means untouched rather than unreadable."""
return _SpendReading(value=spend, state="live" if spend is not None else "no_counter")
async def _read_spend(plan: _PlannedBudget, deps: KeyBudgetResolverDeps) -> _SpendReading:
source: Final = plan.spend_source
try:
match source:
case _RecordedSpend():
return source.value
return _SpendReading(value=source.value, state="live")
case _CounterSpend():
return await deps.read_spend(
counter_key=source.counter_key,
fallback_spend=source.fallback_spend,
max_budget=plan.max_budget,
window_entity_type=source.window_entity_type,
window_entity_id=source.window_entity_id,
window_start=source.window_start,
fallback_authoritative=source.fallback_authoritative,
return _SpendReading(
value=await deps.read_spend(
counter_key=source.counter_key,
fallback_spend=source.fallback_spend,
max_budget=plan.max_budget,
window_entity_type=source.window_entity_type,
window_entity_id=source.window_entity_id,
window_start=source.window_start,
fallback_authoritative=source.fallback_authoritative,
),
state="live",
)
case _KeyModelSpend():
return await deps.read_key_model_spend(
entity_id=source.key_hash, models=source.models, budget_config=source.budget_config
return _model_reading(
await deps.read_key_model_spend(
entity_id=source.key_hash, models=source.models, budget_config=source.budget_config
)
)
case _EndUserModelSpend():
return await deps.read_end_user_model_spend(
entity_id=source.end_user_id, models=source.models, budget_config=source.budget_config
return _model_reading(
await deps.read_end_user_model_spend(
entity_id=source.end_user_id, models=source.models, budget_config=source.budget_config
)
)
case _:
assert_never(source)
except Exception: # noqa: BLE001 # one unreadable counter must not blank the whole report
verbose_proxy_logger.exception("Unable to read live spend for budget scope %s", plan.scope)
return None
return _SpendReading(value=None, state="unavailable")
def _effective_comparison(plan: _PlannedBudget, reservation_enabled: bool) -> BudgetComparison:
@ -491,27 +551,23 @@ def _effective_comparison(plan: _PlannedBudget, reservation_enabled: bool) -> Bu
return ">="
def _entry_note(plan: _PlannedBudget, spend: float | None, comparison: BudgetComparison) -> str | None:
if spend is None:
return (
_MODEL_BUDGET_COLD_NOTE
if isinstance(plan.spend_source, _KeyModelSpend | _EndUserModelSpend)
else _SPEND_UNREADABLE_NOTE
)
if comparison == plan.comparison:
return plan.note
return _RESERVATION_NOTE if plan.note is None else f"{_RESERVATION_NOTE}; {plan.note}"
def _entry_notes(
plan: _PlannedBudget, reading: _SpendReading, comparison: BudgetComparison
) -> tuple[KeyBudgetNote, ...]:
tightened: Final = () if comparison == plan.comparison else (_RESERVATION_NOTE,)
cold: Final = (_MODEL_BUDGET_COLD_NOTE,) if reading.state == "no_counter" else ()
return (*tightened, *plan.notes, *cold)
def _to_entry(plan: _PlannedBudget, spend: float | None, reservation_enabled: bool) -> KeyBudgetEntry:
def _to_entry(plan: _PlannedBudget, reading: _SpendReading, reservation_enabled: bool) -> KeyBudgetEntry:
comparison: Final = _effective_comparison(plan=plan, reservation_enabled=reservation_enabled)
spend: Final = reading.value
exceeded: Final = (
plan.max_budget is not None
and spend is not None
and (spend >= plan.max_budget if comparison == ">=" else spend > plan.max_budget)
)
status: Final[BudgetStatus] = "unlimited" if plan.max_budget is None else ("exceeded" if exceeded else "ok")
note: Final = _entry_note(plan=plan, spend=spend, comparison=comparison)
return KeyBudgetEntry(
scope=plan.scope,
entity_type=_ENTITY_TYPE_BY_SCOPE[plan.scope],
@ -520,6 +576,7 @@ def _to_entry(plan: _PlannedBudget, spend: float | None, reservation_enabled: bo
enforcement=plan.enforcement,
max_budget=plan.max_budget,
spend=spend,
spend_state=reading.state,
remaining=(plan.max_budget - spend) if plan.max_budget is not None and spend is not None else None,
comparison=comparison,
budget_duration=plan.budget_duration,
@ -527,7 +584,7 @@ def _to_entry(plan: _PlannedBudget, spend: float | None, reservation_enabled: bo
window_start=plan.window_start,
source=plan.source,
status=status,
note=note,
notes=_entry_notes(plan=plan, reading=reading, comparison=comparison),
)
@ -834,7 +891,7 @@ def _plan_key(context: _KeyBudgetContext) -> tuple[_PlannedBudget, ...]:
spend_source=_CounterSpend(counter_key=key_spend_counter(token.token), fallback_spend=token.spend or 0.0),
budget_duration=token.budget_duration,
budget_reset_at=token.budget_reset_at,
note=_THROTTLE_NOTE if should_throttle_budget_exceeded(token) else None,
notes=(_THROTTLE_NOTE,) if should_throttle_budget_exceeded(token) else (),
)
soft: Final = _PlannedBudget(
scope="key",
@ -847,7 +904,7 @@ def _plan_key(context: _KeyBudgetContext) -> tuple[_PlannedBudget, ...]:
spend_source=_RecordedSpend(token.spend or 0.0),
budget_duration=token.budget_duration,
budget_reset_at=token.budget_reset_at,
note=_ALERT_ONLY_NOTE,
notes=(_ALERT_ONLY_NOTE,),
)
return (hard, soft)
@ -873,7 +930,7 @@ def _plan_key_windows(context: _KeyBudgetContext) -> tuple[_PlannedBudget, ...]:
budget_duration=window.budget_duration,
budget_reset_at=window.reset_at,
window_start=get_budget_window_start(window.model_dump()),
note=_ROLLING_WINDOW_NOTE,
notes=(_ROLLING_WINDOW_NOTE,),
)
for window in context.token_inputs.budget_limits
)
@ -909,7 +966,7 @@ def _plan_key_models(context: _KeyBudgetContext) -> tuple[_PlannedBudget, ...]:
budget_config=config,
),
budget_duration=config.budget_duration,
note=_MODEL_BUDGET_NOTE,
notes=(_MODEL_BUDGET_NOTE,),
)
for model, config in configured.items()
)
@ -947,7 +1004,7 @@ def _plan_team(context: _KeyBudgetContext) -> tuple[_PlannedBudget, ...]:
spend_source=_RecordedSpend(team.spend or 0.0),
budget_duration=team.budget_duration,
budget_reset_at=team.budget_reset_at,
note=_ALERT_ONLY_NOTE,
notes=(_ALERT_ONLY_NOTE,),
)
return (hard, soft)
@ -975,7 +1032,7 @@ def _plan_team_windows(context: _KeyBudgetContext) -> tuple[_PlannedBudget, ...]
budget_duration=window.budget_duration,
budget_reset_at=window.reset_at,
window_start=get_budget_window_start(window.model_dump()),
note=_ROLLING_WINDOW_NOTE,
notes=(_ROLLING_WINDOW_NOTE,),
)
for window in (team.budget_limits or ())
)
@ -1029,7 +1086,7 @@ def _plan_user(context: _KeyBudgetContext) -> tuple[_PlannedBudget, ...]:
spend_source=_CounterSpend(counter_key=user_spend_counter(user.user_id), fallback_spend=user.spend or 0.0),
budget_duration=user.budget_duration,
budget_reset_at=user.budget_reset_at,
note=None if applies else _USER_ON_TEAM_KEY_NOTE,
notes=() if applies else (_USER_ON_TEAM_KEY_NOTE,),
),
)
@ -1075,7 +1132,7 @@ def _plan_project(context: _KeyBudgetContext) -> tuple[_PlannedBudget, ...]:
spend_source=_RecordedSpend(project.spend or 0.0),
budget_duration=meta.budget_duration,
budget_reset_at=meta.budget_reset_at,
note=_PROJECT_SPEND_NOTE,
notes=(_PROJECT_SPEND_NOTE,),
)
soft: Final = _PlannedBudget(
scope="project",
@ -1088,7 +1145,7 @@ def _plan_project(context: _KeyBudgetContext) -> tuple[_PlannedBudget, ...]:
spend_source=_RecordedSpend(project.spend or 0.0),
budget_duration=meta.budget_duration,
budget_reset_at=meta.budget_reset_at,
note=_ALERT_ONLY_NOTE,
notes=(_ALERT_ONLY_NOTE,),
)
return (hard, soft)
@ -1114,7 +1171,7 @@ def _plan_tags(context: _KeyBudgetContext) -> tuple[_PlannedBudget, ...]:
),
budget_duration=_budget_meta(context, tag.budget_id if tag is not None else None).budget_duration,
budget_reset_at=_budget_meta(context, tag.budget_id if tag is not None else None).budget_reset_at,
note=_TAG_NOTE,
notes=(_TAG_NOTE,),
)
for tag_name, tag in context.tags
)
@ -1151,10 +1208,10 @@ def _plan_end_user(context: _KeyBudgetContext) -> tuple[_PlannedBudget, ...]:
),
budget_duration=meta.budget_duration,
budget_reset_at=meta.budget_reset_at,
note=(
f"{_END_USER_ROUTE_NOTE}; {_CUSTOM_AUTH_END_USER_NOTE}"
notes=(
(_END_USER_ROUTE_NOTE, _CUSTOM_AUTH_END_USER_NOTE)
if context.custom_auth_enabled and token_max_budget is None
else _END_USER_ROUTE_NOTE
else (_END_USER_ROUTE_NOTE,)
),
)
configured: Final = _model_budgets(budget.model_dump() if budget is not None else None)
@ -1173,7 +1230,7 @@ def _plan_end_user(context: _KeyBudgetContext) -> tuple[_PlannedBudget, ...]:
budget_config=config,
),
budget_duration=config.budget_duration,
note=_MODEL_BUDGET_NOTE,
notes=(_MODEL_BUDGET_NOTE,),
)
for model, config in configured.items()
)

View file

@ -3822,12 +3822,15 @@ async def key_budgets_fn(
but places no limit on it
- spend: float | None - Spend as the enforcing check reads it, from the same cross-pod
counter, not the periodically-synced database column
- spend_state: str - Whether `spend` is `live`, missing because no counter exists yet
(`no_counter`), or missing because the read failed (`unavailable`)
- remaining: float | None - `max_budget - spend`, when both are known
- comparison: str - The operator the enforcing check uses, which differs per scope
- budget_duration / budget_reset_at / window_start: When spend next resets to zero
- source: str - Where the limit is configured, e.g. `key.max_budget`, `budget_table:<id>`
- status: str - `unlimited`, `ok` or `exceeded`
- note: str | None - A caveat worth knowing before trusting the row
- notes: list - Caveats worth knowing before trusting the row, each with a stable `code`
to branch on, a `severity` of `info` or `warning`, and human-facing `text`
Example Curl:
```

View file

@ -16693,7 +16693,8 @@ async def test_key_budgets_reports_every_scope_that_applies():
assert by_scope[("team_member", "hard")].budget_reset_at == _BUDGETS_RESET_AT
assert by_scope[("organization", "hard")].entity_label == "Reporting Org"
assert by_scope[("end_user_model", "hard")].entity_id == "claude-sonnet-4-5"
assert by_scope[("project", "hard")].note is not None and "never incremented" in by_scope[("project", "hard")].note
project_notes = [note.code for note in by_scope[("project", "hard")].notes]
assert "project_spend_not_tracked" in project_notes
@pytest.mark.asyncio
@ -16860,7 +16861,7 @@ async def test_key_budgets_keep_soft_budgets_on_their_alerting_operator():
soft = [entry for entry in budgets if entry.enforcement == "soft"]
assert soft, "expected the fully populated world to configure soft budgets"
assert all(entry.comparison == ">=" for entry in soft)
assert all(_RESERVATION_NOTE not in (entry.note or "") for entry in soft)
assert all(_RESERVATION_NOTE not in entry.notes for entry in soft)
@pytest.mark.asyncio
@ -16870,8 +16871,8 @@ async def test_key_budgets_explain_a_scope_whose_operator_the_reservation_tighte
team = next(e for e in budgets if e.scope == "team" and e.enforcement == "hard")
key = next(e for e in budgets if e.scope == "key" and e.enforcement == "hard")
assert _RESERVATION_NOTE in (team.note or "")
assert _RESERVATION_NOTE not in (key.note or ""), "the key check already blocks at >=, nothing was tightened"
assert _RESERVATION_NOTE in team.notes
assert _RESERVATION_NOTE not in key.notes, "the key check already blocks at >=, nothing was tightened"
@pytest.mark.asyncio
@ -16900,7 +16901,7 @@ async def test_key_budgets_report_the_personal_budget_as_inapplicable_on_a_team_
user_entry = next(entry for entry in budgets if entry.scope == "user")
assert user_entry.max_budget is None
assert user_entry.note is not None and "apply_user_budget_to_team_keys" in user_entry.note
assert "user_budget_not_applied_to_team_key" in [note.code for note in user_entry.notes]
@pytest.mark.asyncio
@ -16923,7 +16924,7 @@ async def test_key_budgets_skip_scopes_that_do_not_exist_for_the_key():
assert scopes == {"proxy", "key", "user"}
user_entry = next(entry for entry in budgets if entry.scope == "user")
assert user_entry.max_budget == 400.0
assert user_entry.note is None
assert user_entry.notes == ()
@_budgets_contextlib.contextmanager
@ -16971,6 +16972,7 @@ async def test_key_budgets_route_returns_the_resolved_budgets():
enforcement="hard",
max_budget=100.0,
spend=91.0,
spend_state="live",
remaining=9.0,
comparison=">=",
source="key.max_budget",
@ -16992,6 +16994,7 @@ async def test_key_budgets_route_returns_the_resolved_budgets():
"enforcement": "hard",
"max_budget": 100.0,
"spend": 91.0,
"spend_state": "live",
"remaining": 9.0,
"comparison": ">=",
"budget_duration": None,
@ -16999,7 +17002,7 @@ async def test_key_budgets_route_returns_the_resolved_budgets():
"window_start": None,
"source": "key.max_budget",
"status": "ok",
"note": None,
"notes": [],
}
]
@ -17090,7 +17093,8 @@ async def test_key_budgets_say_a_per_model_counter_is_missing_rather_than_claimi
warm_entry = next(e for e in budgets if e.scope == "key_model" and e.entity_id == "gpt-5")
assert warm_entry.spend == 6.0
assert warm_entry.note != _MODEL_BUDGET_COLD_NOTE
assert warm_entry.spend_state == "live"
assert _MODEL_BUDGET_COLD_NOTE not in warm_entry.notes
cold = _RecordingModelSpendReader({})
with _budgets_world(**_fully_populated_world()):
@ -17100,7 +17104,8 @@ async def test_key_budgets_say_a_per_model_counter_is_missing_rather_than_claimi
cold_entry = next(e for e in cold_budgets if e.scope == "key_model" and e.entity_id == "gpt-5")
assert cold_entry.spend is None
assert cold_entry.note == _MODEL_BUDGET_COLD_NOTE
assert cold_entry.spend_state == "no_counter"
assert _MODEL_BUDGET_COLD_NOTE in cold_entry.notes
@pytest.mark.asyncio
@ -17168,8 +17173,8 @@ async def test_key_budgets_warn_that_custom_auth_can_hide_an_end_user_cap():
plain = next(e for e in budgets if e.scope == "end_user")
warned = next(e for e in with_custom_auth if e.scope == "end_user")
assert "custom auth" not in (plain.note or "")
assert "custom auth" in (warned.note or "")
assert "custom_auth_may_override_end_user_cap" not in [note.code for note in plain.notes]
assert "custom_auth_may_override_end_user_cap" in [note.code for note in warned.notes]
@pytest.mark.parametrize("max_budget", [0, 0.0, -5.0])
@ -17197,6 +17202,84 @@ async def test_key_budgets_treat_a_non_positive_cap_as_unset(max_budget):
assert by_scope["key_model"].status == "unlimited"
@pytest.mark.asyncio
async def test_key_budgets_call_an_unreadable_counter_unreadable_rather_than_reporting_no_spend():
"""A null spend that means "the read failed" renders as untouched headroom unless the row says so."""
async def _explode(**kwargs):
raise RuntimeError("redis is down")
with _budgets_world(**_fully_populated_world()):
budgets = await resolve_key_budgets(
valid_token=_budgets_token(), end_user_id=None, deps=_budgets_deps(read_spend=_explode)
)
key_entry = next(e for e in budgets if e.scope == "key" and e.enforcement == "hard")
assert key_entry.spend is None
assert key_entry.spend_state == "unavailable"
assert key_entry.remaining is None
assert "unavailable" not in [note.code for note in key_entry.notes], "a failed read is not a caveat"
project_entry = next(e for e in budgets if e.scope == "project" and e.enforcement == "hard")
assert project_entry.spend_state == "live", "the recorded-spend scopes do not go through the counter"
@pytest.mark.asyncio
async def test_key_budgets_separate_caveats_that_rule_a_row_out_from_caveats_that_change_how_to_read_it():
"""Severity is what lets a reader skip a budget that cannot trip without skipping one that blocks early."""
budgets = await _budgets_at_limit()
severity_by_code = {note.code: note.severity for entry in budgets for note in entry.notes}
assert severity_by_code["project_spend_not_tracked"] == "info"
assert severity_by_code["alert_only"] == "info"
assert severity_by_code["reservation_blocks_at_limit"] == "warning"
assert severity_by_code["rolling_window"] == "warning"
@pytest.mark.asyncio
async def test_key_budgets_emit_each_caveat_as_its_own_note_instead_of_one_joined_string():
"""The widest row carries three caveats; joining them into prose is what forced the UI to guess at width."""
world = _fully_populated_world()
with _budgets_world(**world):
budgets = await resolve_key_budgets(
valid_token=_budgets_token(),
end_user_id="end-user-budgets",
deps=dataclasses.replace(
_budgets_deps(
read_spend=_RecordingSpendReader(_BUDGETS_SPEND_AT_LIMIT),
general_settings={"apply_user_budget_to_team_keys": True},
),
custom_auth_enabled=True,
),
)
end_user = next(e for e in budgets if e.scope == "end_user")
assert [note.code for note in end_user.notes] == [
"reservation_blocks_at_limit",
"end_user_route_only",
"custom_auth_may_override_end_user_cap",
]
assert all("; " not in note.text for note in end_user.notes)
@pytest.mark.asyncio
async def test_key_budgets_always_send_notes_as_a_list_so_a_client_never_branches_on_null():
"""An absent caveat is an empty list; `null` would make every consumer special-case the field."""
with _budgets_world(**_fully_populated_world()):
budgets = await resolve_key_budgets(
valid_token=_budgets_token(), end_user_id="end-user-budgets", deps=_budgets_deps()
)
serialized = [entry.model_dump(mode="json") for entry in budgets]
assert all(isinstance(row["notes"], list) for row in serialized)
assert any(row["notes"] == [] for row in serialized)
assert all(
set(note) == {"code", "severity", "text"} and note["severity"] in {"info", "warning"} and note["text"]
for row in serialized
for note in row["notes"]
)
@pytest.mark.parametrize(
"role",
[