Skip to content

Commit d518218

Browse files
authored
feat(llm-gateway): report org credit-bucket spend on the usage endpoint (#71404)
## Problem PostHog Code is cutting over to usage-based billing, and the desktop app wants to show real org spend ("used $12.40 of your $50 limit") in the titlebar and on the Plan & usage page. Today `GET /v1/usage/{product}` carries only per-user percentages against internal rate valves plus an `ai_credits.exhausted` boolean, so clients have nothing to render a dollar amount from. The numbers already exist in `organization.usage`, synced from billing. ## Changes - `GET /api/projects/{id}/quota_limits/` now returns `usage` and `limit` per resource, read from the org's synced billing numbers. `usage` is the same `usage + todays_usage` sum the quota limiter compares against the limit. The `limited` boolean stays authoritative for gating (grace periods and refund offsets live only in the limiting decision). Both fields are null when billing hasn't synced the resource. - The gateway's quota resolver parses those numbers, converts credit buckets to USD (1 credit = $0.01) and carries them through its Redis cache. Fail-open paths keep them as null, meaning unknown, never $0. - `GET /v1/usage/{product}` exposes them as `ai_credits.used_usd` and `ai_credits.limit_usd`. Additive and optional, so old clients are unaffected. The per-user burst/sustained buckets still expose only percentages (`test_response_has_no_usd_fields` continues to guard that). The valve limits stay internal; the org's own bucket spend is the user's billing data. > [!NOTE] > The wire change is additive on both hops. Old gateway against new Django ignores the new keys; new gateway against old Django reads null and reports unknown. ## How did you test this code? - Gateway: full unit suite passes locally (1240 tests). New regressions covered that no existing test did: Django responses without numbers read as unknown rather than $0, USD numbers survive the Redis cache round trip (a one-sided cache would flash the numbers in and out between hit and miss), fail-open cache entries carry null numbers, and the usage endpoint forwards the resolver's numbers onto `ai_credits`. - Django: extended `test_quota_limits.py` with a synced-org case (1500 + 200 credits of 2000 reports usage 1700, a synced-but-unlimited bucket reports a null limit, a never-synced resource reports nulls, not zeros). I could not run the Django suite in this sandbox; CI covers it. Ruff (pinned version) is clean. - mypy on the gateway files adds no new errors (two pre-existing ones in `quota_resolver.py` are untouched). ## Automatic notifications - [ ] Publish to changelog? - [ ] Alert Sales and Marketing teams? ## 🤖 Agent context **Autonomy:** Human-driven (agent-assisted) Authored by Claude (PostHog Code cloud session) at Adam's request, as the backend half of the usage-based billing client work in [PostHog/code#3487](PostHog/code#3487), which wants to render org spend for both free-tier and subscribed orgs. Main decision: Django reports raw native units generically for every resource, and the credits-to-USD conversion lives in the gateway where the client contract is defined, rather than special-casing credit buckets in Django. Absent-means-unknown is preserved end to end so a resolver blip or unsynced org never renders as $0 spent. --- *Created with [PostHog Code](https://posthog.com/code?ref=pr)*
1 parent a9bbb06 commit d518218

7 files changed

Lines changed: 193 additions & 16 deletions

File tree

ee/api/quota_limits.py

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,34 @@ class QuotaResourceLimitSerializer(serializers.Serializer):
2727
limited = serializers.BooleanField(
2828
help_text="True when the team is currently over its quota for this resource and limits are in effect.",
2929
)
30+
usage = serializers.FloatField(
31+
allow_null=True,
32+
help_text=(
33+
"Units of this resource the organization has used so far this billing period, in the "
34+
"resource's native unit (credits for credit buckets). Null when billing hasn't synced "
35+
"usage for the resource."
36+
),
37+
)
38+
limit = serializers.FloatField(
39+
allow_null=True,
40+
help_text="The organization's limit for this resource in the same unit. Null when unlimited or unknown.",
41+
)
42+
43+
44+
def _resource_usage(summary: dict[str, Any]) -> float | None:
45+
"""usage + todays_usage, the sum the quota limiter compares against the limit.
46+
47+
None rather than 0 when billing has never synced the resource, so clients read
48+
it as unknown, not "$0 spent". The `limited` boolean stays authoritative for
49+
gating; grace periods and refund offsets live only in that limiting decision.
50+
"""
51+
if not summary:
52+
return None
53+
usage = summary.get("usage")
54+
todays_usage = summary.get("todays_usage")
55+
if usage is None and todays_usage is None:
56+
return None
57+
return (usage or 0) + (todays_usage or 0)
3058

3159

3260
class QuotaLimitsResponseSerializer(serializers.Serializer):
@@ -62,16 +90,19 @@ class QuotaLimitsViewSet(TeamAndOrgViewSetMixin, viewsets.ViewSet):
6290
responses={200: QuotaLimitsResponseSerializer},
6391
)
6492
def list(self, request: Request, *args: Any, **kwargs: Any) -> Response:
65-
limited = {
66-
resource.value: {
93+
org_usage = self.team.organization.usage or {}
94+
limited = {}
95+
for resource in QuotaResource:
96+
summary = org_usage.get(resource.value) or {}
97+
limited[resource.value] = {
6798
"limited": is_team_limited(
6899
self.team.api_token,
69100
resource,
70101
QuotaLimitingCaches.QUOTA_LIMITER_CACHE_KEY,
71102
),
103+
"usage": _resource_usage(summary),
104+
"limit": summary.get("limit"),
72105
}
73-
for resource in QuotaResource
74-
}
75106
return Response(
76107
QuotaLimitsResponseSerializer(
77108
{

ee/api/test/test_quota_limits.py

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ def test_session_auth_returns_under_quota_when_team_not_limited(self) -> None:
5353
response = self.client.get(self._url())
5454
self.assertEqual(response.status_code, status.HTTP_200_OK)
5555
data = response.json()
56-
self.assertEqual(data["limited"]["ai_credits"], {"limited": False})
56+
self.assertEqual(data["limited"]["ai_credits"], {"limited": False, "usage": None, "limit": None})
5757
# Org holds no billing-granted Code usage feature -> reads as not paying
5858
self.assertIs(data["code_usage_billing_active"], False)
5959

@@ -71,19 +71,42 @@ def test_reports_code_usage_billing_state(self) -> None:
7171
self.assertEqual(response.status_code, status.HTTP_200_OK)
7272
self.assertIs(response.json()["code_usage_billing_active"], True)
7373

74+
def test_reports_org_usage_and_limit_for_synced_resources(self) -> None:
75+
# The LLM gateway forwards these to clients (PostHog Code renders
76+
# "used $X of $Y"); usage mirrors the limiter's usage + todays_usage sum.
77+
self.organization.usage = {
78+
"period": ["2026-07-01T00:00:00Z", "2026-08-01T00:00:00Z"],
79+
"posthog_code_credits": {"usage": 1500, "todays_usage": 200, "limit": 2000},
80+
"ai_credits": {"usage": 50, "todays_usage": 0},
81+
"signals_credits": {"usage": None, "todays_usage": None, "limit": 5000},
82+
}
83+
self.organization.save()
84+
85+
response = self.client.get(self._url())
86+
87+
self.assertEqual(response.status_code, status.HTTP_200_OK)
88+
limited = response.json()["limited"]
89+
self.assertEqual(limited["posthog_code_credits"], {"limited": False, "usage": 1700, "limit": 2000})
90+
# Synced but unlimited: usage without a limit.
91+
self.assertEqual(limited["ai_credits"], {"limited": False, "usage": 50, "limit": None})
92+
# Synced with a limit but null usage figures: unknown usage, not zero.
93+
self.assertEqual(limited["signals_credits"], {"limited": False, "usage": None, "limit": 5000})
94+
# Never synced: unknown, not zero.
95+
self.assertEqual(limited["events"], {"limited": False, "usage": None, "limit": None})
96+
7497
def test_returns_limited_when_team_is_over_quota(self) -> None:
7598
self._set_ai_credits_limit(self.team.api_token, 9_999_999_999)
7699

77100
response = self.client.get(self._url())
78101
self.assertEqual(response.status_code, status.HTTP_200_OK)
79-
self.assertEqual(response.json()["limited"]["ai_credits"], {"limited": True})
102+
self.assertEqual(response.json()["limited"]["ai_credits"], {"limited": True, "usage": None, "limit": None})
80103

81104
def test_returns_unlimited_when_limit_has_already_expired(self) -> None:
82105
self._set_ai_credits_limit(self.team.api_token, 1) # epoch 1970
83106

84107
response = self.client.get(self._url())
85108
self.assertEqual(response.status_code, status.HTTP_200_OK)
86-
self.assertEqual(response.json()["limited"]["ai_credits"], {"limited": False})
109+
self.assertEqual(response.json()["limited"]["ai_credits"], {"limited": False, "usage": None, "limit": None})
87110

88111
def test_personal_api_key_auth_works(self) -> None:
89112
self.client.logout()
@@ -102,7 +125,7 @@ def test_personal_api_key_auth_works(self) -> None:
102125
headers={"authorization": f"Bearer {raw_key}"},
103126
)
104127
self.assertEqual(response.status_code, status.HTTP_200_OK)
105-
self.assertEqual(response.json()["limited"]["ai_credits"], {"limited": True})
128+
self.assertEqual(response.json()["limited"]["ai_credits"], {"limited": True, "usage": None, "limit": None})
106129

107130
def test_user_not_in_teams_org_is_forbidden(self) -> None:
108131
other_org = Organization.objects.create(name="other-org")
@@ -177,5 +200,5 @@ def test_multi_team_user_gets_per_team_answers(self) -> None:
177200
resp_self = self.client.get(self._url())
178201
resp_other = self.client.get(self._url(other_team.pk))
179202

180-
self.assertEqual(resp_self.json()["limited"]["ai_credits"], {"limited": True})
181-
self.assertEqual(resp_other.json()["limited"]["ai_credits"], {"limited": False})
203+
self.assertEqual(resp_self.json()["limited"]["ai_credits"], {"limited": True, "usage": None, "limit": None})
204+
self.assertEqual(resp_other.json()["limited"]["ai_credits"], {"limited": False, "usage": None, "limit": None})

services/llm-gateway/src/llm_gateway/api/usage.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ class CostLimitStatus(BaseModel):
3737

3838
class AiCreditsStatus(BaseModel):
3939
exhausted: bool
40+
# Org-level bucket spend this billing period. None means unknown (unsynced
41+
# org, resolver fail-open) — clients must not render None as $0.
42+
used_usd: float | None = None
43+
limit_usd: float | None = None
4044

4145

4246
class UsageResponse(BaseModel):
@@ -139,7 +143,11 @@ async def get_usage(
139143
user_id=user.user_id,
140144
burst=burst_status,
141145
sustained=sustained_status,
142-
ai_credits=AiCreditsStatus(exhausted=credits_exhausted),
146+
ai_credits=AiCreditsStatus(
147+
exhausted=credits_exhausted,
148+
used_usd=quota_status.used_usd,
149+
limit_usd=quota_status.limit_usd,
150+
),
143151
is_rate_limited=burst_status.exceeded or sustained_status.exceeded or credits_exhausted,
144152
is_pro=is_pro_plan(plan_info.plan_key),
145153
code_usage_subscribed=quota_status.code_usage_billing_active,

services/llm-gateway/src/llm_gateway/services/quota_resolver.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,10 +59,30 @@
5959
)
6060

6161

62+
# Credit buckets are denominated in billing credits priced at one cent each
63+
# (the billing product config for AI credits and `posthog_code_usage`).
64+
_USD_PER_CREDIT = 0.01
65+
66+
6267
@dataclass
6368
class QuotaResourceStatus:
6469
limited: bool
6570
code_usage_billing_active: bool = False
71+
# The org's bucket spend and limit this billing period, from billing's
72+
# synced numbers. None means unknown (unsynced org, fail-open) — never $0.
73+
used_usd: float | None = None
74+
limit_usd: float | None = None
75+
76+
77+
def _optional_number(value: object) -> float | None:
78+
if isinstance(value, bool) or not isinstance(value, (int, float)):
79+
return None
80+
return float(value)
81+
82+
83+
def _credits_to_usd(credits: object) -> float | None:
84+
value = _optional_number(credits)
85+
return None if value is None else round(value * _USD_PER_CREDIT, 2)
6686

6787

6888
class _TransientUpstreamError(Exception):
@@ -187,6 +207,8 @@ async def _fetch(self, resource_key: str, team_id: int, auth_header: str) -> tup
187207
return QuotaResourceStatus(
188208
limited=bool(resource.get("limited")),
189209
code_usage_billing_active=bool(data.get("code_usage_billing_active")),
210+
used_usd=_credits_to_usd(resource.get("usage")),
211+
limit_usd=_credits_to_usd(resource.get("limit")),
190212
), self._cache_ttl
191213

192214
async def _get_cached(self, resource_key: str, team_id: int) -> QuotaResourceStatus | None:
@@ -202,6 +224,8 @@ async def _get_cached(self, resource_key: str, team_id: int) -> QuotaResourceSta
202224
# Entries written before this field existed read as False (capped)
203225
# until the TTL turns them over.
204226
code_usage_billing_active=bool(payload.get("code_usage_billing_active")),
227+
used_usd=_optional_number(payload.get("used_usd")),
228+
limit_usd=_optional_number(payload.get("limit_usd")),
205229
)
206230
except Exception:
207231
logger.debug("quota_cache_read_failed", resource=resource_key, team_id=team_id)
@@ -212,7 +236,12 @@ async def _set_cached(self, resource_key: str, team_id: int, status: QuotaResour
212236
return
213237
try:
214238
payload = json.dumps(
215-
{"limited": status.limited, "code_usage_billing_active": status.code_usage_billing_active}
239+
{
240+
"limited": status.limited,
241+
"code_usage_billing_active": status.code_usage_billing_active,
242+
"used_usd": status.used_usd,
243+
"limit_usd": status.limit_usd,
244+
}
216245
)
217246
await self._redis.set(_redis_key(resource_key, team_id), payload, ex=ttl)
218247
except Exception:

services/llm-gateway/tests/test_quota_resolver.py

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -223,12 +223,66 @@ async def test_billing_flag_falls_back_to_last_known_value_on_fetch_failure(self
223223
assert json.loads(redis.store[_redis_key("ai_credits", 42)]) == {
224224
"limited": False,
225225
"code_usage_billing_active": True,
226+
"used_usd": None,
227+
"limit_usd": None,
226228
}
227229
assert redis.ttls[_redis_key("ai_credits", 42)] == _FAIL_OPEN_CACHE_TTL_SECONDS
228230
else:
229231
# 4xx is caller-specific and must not repopulate the shared entry.
230232
assert _redis_key("ai_credits", 42) not in redis.store
231233

234+
@pytest.mark.asyncio
235+
async def test_parses_credit_numbers_into_usd(self) -> None:
236+
# Django reports credit-bucket usage/limit in credits (1 credit = $0.01);
237+
# clients get dollars.
238+
http_client = _make_http_client(
239+
_make_response(
240+
200,
241+
{"limited": {"posthog_code_credits": {"limited": False, "usage": 1234, "limit": 2000}}},
242+
)
243+
)
244+
resolver = QuotaResolver(redis=None, http_client=http_client)
245+
246+
status = await resolver.get_resource_status("posthog_code_credits", team_id=42, auth_header="Bearer phx_test")
247+
248+
assert status.used_usd == 12.34
249+
assert status.limit_usd == 20.0
250+
251+
@pytest.mark.asyncio
252+
async def test_missing_usage_numbers_read_as_unknown(self) -> None:
253+
# Old Django responses and unsynced orgs carry no numbers — clients must
254+
# see unknown, never $0.
255+
http_client = _make_http_client(
256+
_make_response(
257+
200,
258+
{"limited": {"posthog_code_credits": {"limited": False, "usage": None, "limit": None}}},
259+
)
260+
)
261+
resolver = QuotaResolver(redis=None, http_client=http_client)
262+
263+
status = await resolver.get_resource_status("posthog_code_credits", team_id=42, auth_header="Bearer phx_test")
264+
265+
assert status.used_usd is None
266+
assert status.limit_usd is None
267+
268+
@pytest.mark.asyncio
269+
async def test_usd_numbers_round_trip_through_the_cache(self) -> None:
270+
redis = _FakeRedis()
271+
http_client = _make_http_client(
272+
_make_response(
273+
200,
274+
{"limited": {"posthog_code_credits": {"limited": False, "usage": 1234, "limit": 5000}}},
275+
)
276+
)
277+
resolver = QuotaResolver(redis=redis, http_client=http_client) # type: ignore[arg-type]
278+
279+
first = await resolver.get_resource_status("posthog_code_credits", team_id=42, auth_header="Bearer phx_test")
280+
cached = await resolver.get_resource_status("posthog_code_credits", team_id=42, auth_header="Bearer phx_test")
281+
282+
assert (first.used_usd, first.limit_usd) == (12.34, 50.0)
283+
assert (cached.used_usd, cached.limit_usd) == (12.34, 50.0)
284+
assert http_client.get.await_count == 1
285+
232286
@pytest.mark.asyncio
233287
async def test_fetches_and_parses_unlimited_response(self) -> None:
234288
http_client = _make_http_client(
@@ -328,7 +382,12 @@ async def test_writes_cache_on_miss(self) -> None:
328382
quota_writes = [c for c in redis.set.await_args_list if c.args[0] == _redis_key("ai_credits", 42)]
329383
assert len(quota_writes) == 1
330384
call = quota_writes[0]
331-
assert json.loads(call.args[1]) == {"limited": True, "code_usage_billing_active": False}
385+
assert json.loads(call.args[1]) == {
386+
"limited": True,
387+
"code_usage_billing_active": False,
388+
"used_usd": None,
389+
"limit_usd": None,
390+
}
332391
# Successful fetches use the gateway settings default of 5 minutes.
333392
assert call.kwargs.get("ex") == 300
334393
assert redis.set.await_count == 2

services/llm-gateway/tests/test_usage.py

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -378,7 +378,7 @@ def test_credits_reflect_products_own_bucket(self, authenticated_usage_client: T
378378
)
379379
assert response.status_code == 200
380380
data = response.json()
381-
assert data["ai_credits"] == {"exhausted": True}
381+
assert data["ai_credits"] == {"exhausted": True, "used_usd": None, "limit_usd": None}
382382
assert data["is_rate_limited"] is True
383383
assert resolver_mock.call_args.args[0] == "posthog_code_credits"
384384

@@ -404,7 +404,7 @@ def test_exhausted_bucket_reported_for_every_caller(
404404
)
405405
assert response.status_code == 200
406406
data = response.json()
407-
assert data["ai_credits"] == {"exhausted": True}
407+
assert data["ai_credits"] == {"exhausted": True, "used_usd": None, "limit_usd": None}
408408
assert data["is_rate_limited"] is True
409409

410410
def test_ai_credits_reflects_resolver_for_billable_product(self, authenticated_usage_client: TestClient) -> None:
@@ -420,10 +420,27 @@ def test_ai_credits_reflects_resolver_for_billable_product(self, authenticated_u
420420
)
421421
assert response.status_code == 200
422422
data = response.json()
423-
assert data["ai_credits"] == {"exhausted": True}
423+
assert data["ai_credits"] == {"exhausted": True, "used_usd": None, "limit_usd": None}
424424
assert data["is_rate_limited"] is True
425425
assert resolver_mock.call_args.args[0] == "ai_credits"
426426

427+
def test_ai_credits_carries_org_spend_numbers(self, authenticated_usage_client: TestClient) -> None:
428+
"""PostHog Code renders "used $X of $Y" (titlebar, plans page) off these
429+
numbers; None must stay None so clients read unknown, not $0."""
430+
from llm_gateway.services.quota_resolver import QuotaResourceStatus
431+
432+
app = authenticated_usage_client.app
433+
app.state.quota_resolver.get_resource_status = AsyncMock(
434+
return_value=QuotaResourceStatus(limited=False, used_usd=12.4, limit_usd=50.0)
435+
)
436+
437+
response = authenticated_usage_client.get(
438+
"/v1/usage/posthog_code",
439+
headers={"Authorization": "Bearer phx_test"},
440+
)
441+
assert response.status_code == 200
442+
assert response.json()["ai_credits"] == {"exhausted": False, "used_usd": 12.4, "limit_usd": 50.0}
443+
427444
@pytest.mark.parametrize("billing_active", [True, False])
428445
def test_code_usage_subscribed_reflects_billing_bit(
429446
self, authenticated_usage_client: TestClient, billing_active: bool

services/mcp/src/api/generated.ts

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)