Files
insights/services/llm-gateway/tests/test_billable_credits_throttle.py
zeekay 1b367e2b62 chore: rebase the tree onto upstream master
The tree was a debranded snapshot of upstream taken around 2026-02-20, and
upstream has moved about five months since. This replaces it with upstream
master, mechanically re-derived: bin/debrand does the rename, and the
pipeline and squashed migrations are restored from the shipped tree.

Not a merge. The previous import replaced the tree wholesale in a single
commit, so there is no shared content history to merge against. Reproducing
the rename on the new tree is what actually converges.

What survives: .hanzo/workflows, the Dockerfiles and bin/docker* entrypoints,
compose files, LLM.md, and insights/migrations. The squashed initial
migration is load-bearing — the live database records it by name, so
upstream's ~918 individual migrations cannot come back.

Nine products are dropped as unreferenced. Twenty-one more that we do not run
are kept anyway because the core app still imports them; unpicking those is a
refactor and does not belong in a rebase.

This base does not build yet, and the reasons are not in the rename:
upstream now wants Python 3.13.13 against our 3.12.12 and Django 5.2 against
our 4.2, and the dependency manifests still name upstream packages that we
republish under our own names. Those are decisions, not renames.
2026-08-01 15:44:40 -07:00

132 lines
4.9 KiB
Python

from __future__ import annotations
from unittest.mock import patch
import pytest
from llm_gateway.auth.models import AuthenticatedUser
from llm_gateway.rate_limiting.billable_credits_throttle import BillableCreditThrottle
from llm_gateway.rate_limiting.throttles import ThrottleContext
def _make_user() -> AuthenticatedUser:
return AuthenticatedUser(
user_id=1,
team_id=42,
auth_method="personal_api_key",
distinct_id="distinct-1",
scopes=["llm_gateway:read"],
)
def _make_context(
product: str,
*,
credits_exhausted: bool = False,
plan_key: str | None = None,
seat_missing: bool = False,
code_usage_billed: bool = False,
) -> ThrottleContext:
return ThrottleContext(
user=_make_user(),
product=product,
credits_exhausted=credits_exhausted,
plan_key=plan_key,
seat_missing=seat_missing,
code_usage_billed=code_usage_billed,
)
class TestBillableCreditThrottle:
@pytest.mark.asyncio
async def test_allows_non_billable_product_even_when_exhausted(self) -> None:
# `sherlockhog` is non-billable; exhaustion at the context level is
# irrelevant — the throttle short-circuits before checking the flag.
throttle = BillableCreditThrottle()
result = await throttle.allow_request(_make_context(product="sherlockhog", credits_exhausted=True))
assert result.allowed is True
@pytest.mark.asyncio
async def test_denies_insights_code_when_its_bucket_is_exhausted(self) -> None:
# `insights_code` bills into insights_code_credits; the dependency layer
# resolves exhaustion for that bucket, so the throttle blocks on it —
# with the Code-specific message, not the Insights AI one.
throttle = BillableCreditThrottle()
result = await throttle.allow_request(_make_context(product="insights_code", credits_exhausted=True))
assert result.allowed is False
assert result.status_code == 429
assert "Insights Desktop usage limit" in result.detail
@pytest.mark.asyncio
@pytest.mark.parametrize(
("seat_missing", "code_usage_billed"),
[
# Seatless in a paying org: the org's billing limit is the only
# ceiling (the per-user cap is lifted) - exhaustion must block, or
# usage runs past the org's configured limit.
(True, True),
# Seatless in a non-paying org: the free plan's monthly allocation
# surfaces the same way and must block the same way.
(True, False),
# Every generation is counted into the bucket regardless of seat
# state, so a seat-based carve-out would let the exempted callers
# burn past the limit their own spend is filling.
(False, True),
(False, False),
],
)
async def test_bucket_blocks_regardless_of_seat_or_billing_state(
self, seat_missing: bool, code_usage_billed: bool
) -> None:
throttle = BillableCreditThrottle()
result = await throttle.allow_request(
_make_context(
product="insights_code",
credits_exhausted=True,
seat_missing=seat_missing,
code_usage_billed=code_usage_billed,
)
)
assert result.allowed is False
@pytest.mark.asyncio
@pytest.mark.parametrize("product", ["slack_app", "slack_app_routing", "insights_code"])
async def test_allows_billable_product_when_not_exhausted(self, product: str) -> None:
throttle = BillableCreditThrottle()
result = await throttle.allow_request(_make_context(product=product, credits_exhausted=False))
assert result.allowed is True
@pytest.mark.asyncio
@pytest.mark.parametrize("product", ["slack_app", "slack_app_routing"])
async def test_denies_billable_product_when_exhausted(self, product: str) -> None:
throttle = BillableCreditThrottle()
result = await throttle.allow_request(_make_context(product=product, credits_exhausted=True))
assert result.allowed is False
assert result.status_code == 429
assert result.scope == "billable_credits"
assert "Insights AI credits" in result.detail
assert result.retry_after == 60
@pytest.mark.asyncio
async def test_unmapped_bucket_falls_back_to_generic_detail(self) -> None:
# A bucket added to the enum without a detail entry must still deny
# with a 429, not blow up with a KeyError.
throttle = BillableCreditThrottle()
with patch.dict("llm_gateway.rate_limiting.billable_credits_throttle._BUCKET_EXHAUSTED_DETAIL", clear=True):
result = await throttle.allow_request(_make_context(product="insights_code", credits_exhausted=True))
assert result.allowed is False
assert result.status_code == 429
assert "reached its usage limit" in result.detail