From 60fb4d0e7dcd78c89c07c08cfbf6ff2c564830d7 Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Wed, 15 Jul 2026 23:04:48 -0400 Subject: [PATCH 1/5] feat(llm-gateway): report org credit-bucket spend on the usage endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit quota_limits now returns each resource's usage and limit from the org's synced billing numbers (the same usage + todays_usage sum the limiter compares; the boolean stays authoritative for gating). The gateway converts credit buckets to USD and exposes ai_credits.used_usd / limit_usd, cached alongside the existing bits. Absent numbers mean unknown, never zero — fail-open paths carry None. Generated-By: PostHog Code Task-Id: 1039ea23-9930-44e2-9888-b05cf8b129ec --- ee/api/quota_limits.py | 26 ++++++-- ee/api/test/test_quota_limits.py | 32 ++++++++-- .../llm-gateway/src/llm_gateway/api/usage.py | 10 ++- .../llm_gateway/services/quota_resolver.py | 31 +++++++++- .../llm-gateway/tests/test_quota_resolver.py | 61 ++++++++++++++++++- services/llm-gateway/tests/test_usage.py | 23 ++++++- 6 files changed, 167 insertions(+), 16 deletions(-) diff --git a/ee/api/quota_limits.py b/ee/api/quota_limits.py index 80327e13ea9d..d64a7350a620 100644 --- a/ee/api/quota_limits.py +++ b/ee/api/quota_limits.py @@ -27,6 +27,18 @@ class QuotaResourceLimitSerializer(serializers.Serializer): limited = serializers.BooleanField( help_text="True when the team is currently over its quota for this resource and limits are in effect.", ) + usage = serializers.FloatField( + allow_null=True, + help_text=( + "Units of this resource the organization has used so far this billing period, in the " + "resource's native unit (credits for credit buckets). Null when billing hasn't synced " + "usage for the resource." + ), + ) + limit = serializers.FloatField( + allow_null=True, + help_text="The organization's limit for this resource in the same unit. Null when unlimited or unknown.", + ) class QuotaLimitsResponseSerializer(serializers.Serializer): @@ -62,16 +74,22 @@ class QuotaLimitsViewSet(TeamAndOrgViewSetMixin, viewsets.ViewSet): responses={200: QuotaLimitsResponseSerializer}, ) def list(self, request: Request, *args: Any, **kwargs: Any) -> Response: - limited = { - resource.value: { + org_usage = self.team.organization.usage or {} + limited = {} + for resource in QuotaResource: + summary = org_usage.get(resource.value) or {} + limited[resource.value] = { "limited": is_team_limited( self.team.api_token, resource, QuotaLimitingCaches.QUOTA_LIMITER_CACHE_KEY, ), + # usage + todays_usage is the sum the limiter compares against the limit + # (org_quota_limited_until); the boolean stays authoritative for gating — + # grace periods and refund offsets live only in the limiting decision. + "usage": (summary.get("usage") or 0) + (summary.get("todays_usage") or 0) if summary else None, + "limit": summary.get("limit"), } - for resource in QuotaResource - } return Response( QuotaLimitsResponseSerializer( { diff --git a/ee/api/test/test_quota_limits.py b/ee/api/test/test_quota_limits.py index 81ec1bf9ad45..b6cc5a4ef564 100644 --- a/ee/api/test/test_quota_limits.py +++ b/ee/api/test/test_quota_limits.py @@ -53,7 +53,7 @@ def test_session_auth_returns_under_quota_when_team_not_limited(self) -> None: response = self.client.get(self._url()) self.assertEqual(response.status_code, status.HTTP_200_OK) data = response.json() - self.assertEqual(data["limited"]["ai_credits"], {"limited": False}) + self.assertEqual(data["limited"]["ai_credits"], {"limited": False, "usage": None, "limit": None}) # Org holds no billing-granted Code usage feature -> reads as not paying self.assertIs(data["code_usage_billing_active"], False) @@ -71,19 +71,39 @@ def test_reports_code_usage_billing_state(self) -> None: self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertIs(response.json()["code_usage_billing_active"], True) + def test_reports_org_usage_and_limit_for_synced_resources(self) -> None: + # The LLM gateway forwards these to clients (PostHog Code renders + # "used $X of $Y"); usage mirrors the limiter's usage + todays_usage sum. + self.organization.usage = { + "period": ["2026-07-01T00:00:00Z", "2026-08-01T00:00:00Z"], + "posthog_code_credits": {"usage": 1500, "todays_usage": 200, "limit": 2000}, + "ai_credits": {"usage": 50, "todays_usage": 0}, + } + self.organization.save() + + response = self.client.get(self._url()) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + limited = response.json()["limited"] + self.assertEqual(limited["posthog_code_credits"], {"limited": False, "usage": 1700, "limit": 2000}) + # Synced but unlimited: usage without a limit. + self.assertEqual(limited["ai_credits"], {"limited": False, "usage": 50, "limit": None}) + # Never synced: unknown, not zero. + self.assertEqual(limited["events"], {"limited": False, "usage": None, "limit": None}) + def test_returns_limited_when_team_is_over_quota(self) -> None: self._set_ai_credits_limit(self.team.api_token, 9_999_999_999) response = self.client.get(self._url()) self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.json()["limited"]["ai_credits"], {"limited": True}) + self.assertEqual(response.json()["limited"]["ai_credits"], {"limited": True, "usage": None, "limit": None}) def test_returns_unlimited_when_limit_has_already_expired(self) -> None: self._set_ai_credits_limit(self.team.api_token, 1) # epoch 1970 response = self.client.get(self._url()) self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.json()["limited"]["ai_credits"], {"limited": False}) + self.assertEqual(response.json()["limited"]["ai_credits"], {"limited": False, "usage": None, "limit": None}) def test_personal_api_key_auth_works(self) -> None: self.client.logout() @@ -102,7 +122,7 @@ def test_personal_api_key_auth_works(self) -> None: headers={"authorization": f"Bearer {raw_key}"}, ) self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.json()["limited"]["ai_credits"], {"limited": True}) + self.assertEqual(response.json()["limited"]["ai_credits"], {"limited": True, "usage": None, "limit": None}) def test_user_not_in_teams_org_is_forbidden(self) -> None: other_org = Organization.objects.create(name="other-org") @@ -177,5 +197,5 @@ def test_multi_team_user_gets_per_team_answers(self) -> None: resp_self = self.client.get(self._url()) resp_other = self.client.get(self._url(other_team.pk)) - self.assertEqual(resp_self.json()["limited"]["ai_credits"], {"limited": True}) - self.assertEqual(resp_other.json()["limited"]["ai_credits"], {"limited": False}) + self.assertEqual(resp_self.json()["limited"]["ai_credits"], {"limited": True, "usage": None, "limit": None}) + self.assertEqual(resp_other.json()["limited"]["ai_credits"], {"limited": False, "usage": None, "limit": None}) diff --git a/services/llm-gateway/src/llm_gateway/api/usage.py b/services/llm-gateway/src/llm_gateway/api/usage.py index e98598a7b7ba..1f023e6cddb3 100644 --- a/services/llm-gateway/src/llm_gateway/api/usage.py +++ b/services/llm-gateway/src/llm_gateway/api/usage.py @@ -37,6 +37,10 @@ class CostLimitStatus(BaseModel): class AiCreditsStatus(BaseModel): exhausted: bool + # Org-level bucket spend this billing period. None means unknown (unsynced + # org, resolver fail-open) — clients must not render None as $0. + used_usd: float | None = None + limit_usd: float | None = None class UsageResponse(BaseModel): @@ -139,7 +143,11 @@ async def get_usage( user_id=user.user_id, burst=burst_status, sustained=sustained_status, - ai_credits=AiCreditsStatus(exhausted=credits_exhausted), + ai_credits=AiCreditsStatus( + exhausted=credits_exhausted, + used_usd=quota_status.used_usd, + limit_usd=quota_status.limit_usd, + ), is_rate_limited=burst_status.exceeded or sustained_status.exceeded or credits_exhausted, is_pro=is_pro_plan(plan_info.plan_key), code_usage_subscribed=quota_status.code_usage_billing_active, diff --git a/services/llm-gateway/src/llm_gateway/services/quota_resolver.py b/services/llm-gateway/src/llm_gateway/services/quota_resolver.py index ff7a9eb2c889..6dc2cd38a966 100644 --- a/services/llm-gateway/src/llm_gateway/services/quota_resolver.py +++ b/services/llm-gateway/src/llm_gateway/services/quota_resolver.py @@ -59,10 +59,30 @@ ) +# Credit buckets are denominated in billing credits priced at one cent each +# (the billing product config for AI credits and `posthog_code_usage`). +_USD_PER_CREDIT = 0.01 + + @dataclass class QuotaResourceStatus: limited: bool code_usage_billing_active: bool = False + # The org's bucket spend and limit this billing period, from billing's + # synced numbers. None means unknown (unsynced org, fail-open) — never $0. + used_usd: float | None = None + limit_usd: float | None = None + + +def _optional_number(value: object) -> float | None: + if isinstance(value, bool) or not isinstance(value, (int, float)): + return None + return float(value) + + +def _credits_to_usd(credits: object) -> float | None: + value = _optional_number(credits) + return None if value is None else round(value * _USD_PER_CREDIT, 2) class _TransientUpstreamError(Exception): @@ -187,6 +207,8 @@ async def _fetch(self, resource_key: str, team_id: int, auth_header: str) -> tup return QuotaResourceStatus( limited=bool(resource.get("limited")), code_usage_billing_active=bool(data.get("code_usage_billing_active")), + used_usd=_credits_to_usd(resource.get("usage")), + limit_usd=_credits_to_usd(resource.get("limit")), ), self._cache_ttl 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 # Entries written before this field existed read as False (capped) # until the TTL turns them over. code_usage_billing_active=bool(payload.get("code_usage_billing_active")), + used_usd=_optional_number(payload.get("used_usd")), + limit_usd=_optional_number(payload.get("limit_usd")), ) except Exception: 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 return try: payload = json.dumps( - {"limited": status.limited, "code_usage_billing_active": status.code_usage_billing_active} + { + "limited": status.limited, + "code_usage_billing_active": status.code_usage_billing_active, + "used_usd": status.used_usd, + "limit_usd": status.limit_usd, + } ) await self._redis.set(_redis_key(resource_key, team_id), payload, ex=ttl) except Exception: diff --git a/services/llm-gateway/tests/test_quota_resolver.py b/services/llm-gateway/tests/test_quota_resolver.py index 9409985bf273..583f67ac739b 100644 --- a/services/llm-gateway/tests/test_quota_resolver.py +++ b/services/llm-gateway/tests/test_quota_resolver.py @@ -223,12 +223,66 @@ async def test_billing_flag_falls_back_to_last_known_value_on_fetch_failure(self assert json.loads(redis.store[_redis_key("ai_credits", 42)]) == { "limited": False, "code_usage_billing_active": True, + "used_usd": None, + "limit_usd": None, } assert redis.ttls[_redis_key("ai_credits", 42)] == _FAIL_OPEN_CACHE_TTL_SECONDS else: # 4xx is caller-specific and must not repopulate the shared entry. assert _redis_key("ai_credits", 42) not in redis.store + @pytest.mark.asyncio + async def test_parses_credit_numbers_into_usd(self) -> None: + # Django reports credit-bucket usage/limit in credits (1 credit = $0.01); + # clients get dollars. + http_client = _make_http_client( + _make_response( + 200, + {"limited": {"posthog_code_credits": {"limited": False, "usage": 1234, "limit": 2000}}}, + ) + ) + resolver = QuotaResolver(redis=None, http_client=http_client) + + status = await resolver.get_resource_status("posthog_code_credits", team_id=42, auth_header="Bearer phx_test") + + assert status.used_usd == 12.34 + assert status.limit_usd == 20.0 + + @pytest.mark.asyncio + async def test_missing_usage_numbers_read_as_unknown(self) -> None: + # Old Django responses and unsynced orgs carry no numbers — clients must + # see unknown, never $0. + http_client = _make_http_client( + _make_response( + 200, + {"limited": {"posthog_code_credits": {"limited": False, "usage": None, "limit": None}}}, + ) + ) + resolver = QuotaResolver(redis=None, http_client=http_client) + + status = await resolver.get_resource_status("posthog_code_credits", team_id=42, auth_header="Bearer phx_test") + + assert status.used_usd is None + assert status.limit_usd is None + + @pytest.mark.asyncio + async def test_usd_numbers_round_trip_through_the_cache(self) -> None: + redis = _FakeRedis() + http_client = _make_http_client( + _make_response( + 200, + {"limited": {"posthog_code_credits": {"limited": False, "usage": 1234, "limit": 5000}}}, + ) + ) + resolver = QuotaResolver(redis=redis, http_client=http_client) # type: ignore[arg-type] + + first = await resolver.get_resource_status("posthog_code_credits", team_id=42, auth_header="Bearer phx_test") + cached = await resolver.get_resource_status("posthog_code_credits", team_id=42, auth_header="Bearer phx_test") + + assert (first.used_usd, first.limit_usd) == (12.34, 50.0) + assert (cached.used_usd, cached.limit_usd) == (12.34, 50.0) + assert http_client.get.await_count == 1 + @pytest.mark.asyncio async def test_fetches_and_parses_unlimited_response(self) -> None: http_client = _make_http_client( @@ -328,7 +382,12 @@ async def test_writes_cache_on_miss(self) -> None: quota_writes = [c for c in redis.set.await_args_list if c.args[0] == _redis_key("ai_credits", 42)] assert len(quota_writes) == 1 call = quota_writes[0] - assert json.loads(call.args[1]) == {"limited": True, "code_usage_billing_active": False} + assert json.loads(call.args[1]) == { + "limited": True, + "code_usage_billing_active": False, + "used_usd": None, + "limit_usd": None, + } # Successful fetches use the gateway settings default of 5 minutes. assert call.kwargs.get("ex") == 300 assert redis.set.await_count == 2 diff --git a/services/llm-gateway/tests/test_usage.py b/services/llm-gateway/tests/test_usage.py index 6f3410571585..ae1eb2a073a6 100644 --- a/services/llm-gateway/tests/test_usage.py +++ b/services/llm-gateway/tests/test_usage.py @@ -378,7 +378,7 @@ def test_credits_reflect_products_own_bucket(self, authenticated_usage_client: T ) assert response.status_code == 200 data = response.json() - assert data["ai_credits"] == {"exhausted": True} + assert data["ai_credits"] == {"exhausted": True, "used_usd": None, "limit_usd": None} assert data["is_rate_limited"] is True assert resolver_mock.call_args.args[0] == "posthog_code_credits" @@ -404,7 +404,7 @@ def test_exhausted_bucket_reported_for_every_caller( ) assert response.status_code == 200 data = response.json() - assert data["ai_credits"] == {"exhausted": True} + assert data["ai_credits"] == {"exhausted": True, "used_usd": None, "limit_usd": None} assert data["is_rate_limited"] is True 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 ) assert response.status_code == 200 data = response.json() - assert data["ai_credits"] == {"exhausted": True} + assert data["ai_credits"] == {"exhausted": True, "used_usd": None, "limit_usd": None} assert data["is_rate_limited"] is True assert resolver_mock.call_args.args[0] == "ai_credits" + def test_ai_credits_carries_org_spend_numbers(self, authenticated_usage_client: TestClient) -> None: + """PostHog Code renders "used $X of $Y" (titlebar, plans page) off these + numbers; None must stay None so clients read unknown, not $0.""" + from llm_gateway.services.quota_resolver import QuotaResourceStatus + + app = authenticated_usage_client.app + app.state.quota_resolver.get_resource_status = AsyncMock( + return_value=QuotaResourceStatus(limited=False, used_usd=12.4, limit_usd=50.0) + ) + + response = authenticated_usage_client.get( + "/v1/usage/posthog_code", + headers={"Authorization": "Bearer phx_test"}, + ) + assert response.status_code == 200 + assert response.json()["ai_credits"] == {"exhausted": False, "used_usd": 12.4, "limit_usd": 50.0} + @pytest.mark.parametrize("billing_active", [True, False]) def test_code_usage_subscribed_reflects_billing_bit( self, authenticated_usage_client: TestClient, billing_active: bool From d861eb771e9ca37e2e60c5712097b852b3d04661 Mon Sep 17 00:00:00 2001 From: "tests-posthog[bot]" <250237707+tests-posthog[bot]@users.noreply.github.com> Date: Thu, 16 Jul 2026 03:10:06 +0000 Subject: [PATCH 2/5] chore: update OpenAPI generated types --- services/mcp/src/api/generated.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/services/mcp/src/api/generated.ts b/services/mcp/src/api/generated.ts index 613cd7972120..d933877c4b45 100644 --- a/services/mcp/src/api/generated.ts +++ b/services/mcp/src/api/generated.ts @@ -53117,6 +53117,16 @@ export namespace Schemas { export interface QuotaResourceLimit { /** True when the team is currently over its quota for this resource and limits are in effect. */ limited: boolean; + /** + * Units of this resource the organization has used so far this billing period, in the resource's native unit (credits for credit buckets). Null when billing hasn't synced usage for the resource. + * @nullable + */ + usage: number | null; + /** + * The organization's limit for this resource in the same unit. Null when unlimited or unknown. + * @nullable + */ + limit: number | null; } /** From deca08992e55f53fe642ffedcd1ec19e56e070a0 Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Wed, 15 Jul 2026 23:22:07 -0400 Subject: [PATCH 3/5] refactor(quota-limits): extract per-resource usage helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the dense inline `usage + todays_usage` ternary (and its rationale) out of the response loop into a small `_resource_usage` helper, so the call site reads `"usage": _resource_usage(summary)`. Pure refactor — behavior is identical for synced, synced-unlimited, and never-synced resources. Generated-By: PostHog Code Task-Id: 5e82b22a-9b3c-4809-9fa2-b89a616d373f --- ee/api/quota_limits.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/ee/api/quota_limits.py b/ee/api/quota_limits.py index d64a7350a620..a56f094fe0a4 100644 --- a/ee/api/quota_limits.py +++ b/ee/api/quota_limits.py @@ -41,6 +41,18 @@ class QuotaResourceLimitSerializer(serializers.Serializer): ) +def _resource_usage(summary: dict[str, Any]) -> float | None: + """usage + todays_usage, the sum the quota limiter compares against the limit. + + None rather than 0 when billing has never synced the resource, so clients read + it as unknown, not "$0 spent". The `limited` boolean stays authoritative for + gating; grace periods and refund offsets live only in that limiting decision. + """ + if not summary: + return None + return (summary.get("usage") or 0) + (summary.get("todays_usage") or 0) + + class QuotaLimitsResponseSerializer(serializers.Serializer): limited = serializers.DictField( child=QuotaResourceLimitSerializer(), @@ -84,10 +96,7 @@ def list(self, request: Request, *args: Any, **kwargs: Any) -> Response: resource, QuotaLimitingCaches.QUOTA_LIMITER_CACHE_KEY, ), - # usage + todays_usage is the sum the limiter compares against the limit - # (org_quota_limited_until); the boolean stays authoritative for gating — - # grace periods and refund offsets live only in the limiting decision. - "usage": (summary.get("usage") or 0) + (summary.get("todays_usage") or 0) if summary else None, + "usage": _resource_usage(summary), "limit": summary.get("limit"), } return Response( From 7071ed4c07f60ca473dfc1db185a4617843bbdeb Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Wed, 15 Jul 2026 23:43:34 -0400 Subject: [PATCH 4/5] Update quota_limits.py --- ee/api/quota_limits.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/ee/api/quota_limits.py b/ee/api/quota_limits.py index a56f094fe0a4..25a3b810fac2 100644 --- a/ee/api/quota_limits.py +++ b/ee/api/quota_limits.py @@ -50,7 +50,11 @@ def _resource_usage(summary: dict[str, Any]) -> float | None: """ if not summary: return None - return (summary.get("usage") or 0) + (summary.get("todays_usage") or 0) + usage = summary.get("usage") + todays_usage = summary.get("todays_usage") + if usage is None and todays_usage is None: + return None + return (usage or 0) + (todays_usage or 0) class QuotaLimitsResponseSerializer(serializers.Serializer): From 82e9cd2f1f7c4316f8b2caa1f7aae0c67a879d72 Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Wed, 15 Jul 2026 23:49:54 -0400 Subject: [PATCH 5/5] test(quota-limits): cover null usage figures as unknown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Guards the fix in the previous commit: a resource billing synced with null usage/todays_usage must report usage as null (unknown), not 0, so clients never render "$0 spent" while billing figures are incomplete. Adds a present-but-null case to the synced-resources endpoint test — the existing cases only cover real numbers and never-synced resources. Generated-By: PostHog Code Task-Id: 5e82b22a-9b3c-4809-9fa2-b89a616d373f --- ee/api/test/test_quota_limits.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ee/api/test/test_quota_limits.py b/ee/api/test/test_quota_limits.py index b6cc5a4ef564..56ceb8433b95 100644 --- a/ee/api/test/test_quota_limits.py +++ b/ee/api/test/test_quota_limits.py @@ -78,6 +78,7 @@ def test_reports_org_usage_and_limit_for_synced_resources(self) -> None: "period": ["2026-07-01T00:00:00Z", "2026-08-01T00:00:00Z"], "posthog_code_credits": {"usage": 1500, "todays_usage": 200, "limit": 2000}, "ai_credits": {"usage": 50, "todays_usage": 0}, + "signals_credits": {"usage": None, "todays_usage": None, "limit": 5000}, } self.organization.save() @@ -88,6 +89,8 @@ def test_reports_org_usage_and_limit_for_synced_resources(self) -> None: self.assertEqual(limited["posthog_code_credits"], {"limited": False, "usage": 1700, "limit": 2000}) # Synced but unlimited: usage without a limit. self.assertEqual(limited["ai_credits"], {"limited": False, "usage": 50, "limit": None}) + # Synced with a limit but null usage figures: unknown usage, not zero. + self.assertEqual(limited["signals_credits"], {"limited": False, "usage": None, "limit": 5000}) # Never synced: unknown, not zero. self.assertEqual(limited["events"], {"limited": False, "usage": None, "limit": None})