diff --git a/litellm/proxy/management_endpoints/key_budget_resolver.py b/litellm/proxy/management_endpoints/key_budget_resolver.py index 55b57a01cad..f2297e784d9 100644 --- a/litellm/proxy/management_endpoints/key_budget_resolver.py +++ b/litellm/proxy/management_endpoints/key_budget_resolver.py @@ -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() ) diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index 493422a67b0..0e7b1103a5f 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -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:` - 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: ``` diff --git a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py index e9d08c090f2..77b047e06ca 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py @@ -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", [