-
Notifications
You must be signed in to change notification settings - Fork 3.4k
feat(llm-gateway): report org credit-bucket spend on the usage endpoint #71404
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
60fb4d0
d861eb7
deca089
7071ed4
82e9cd2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -27,6 +27,34 @@ 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.", | ||
| ) | ||
|
|
||
|
|
||
| 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 | ||
| 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): | ||
|
|
@@ -62,16 +90,19 @@ 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": _resource_usage(summary), | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Medium: Organization usage exceeds the project scope This project-scoped endpoint now returns organization-wide totals for every quota resource. An attacker holding a key restricted to one project can use it to read aggregate event, recording, AI, and other usage and limits across the organization, including sibling projects. Restrict the response to data authorized at the project level, or require an organization-level billing permission before returning these fields. |
||
| "limit": summary.get("limit"), | ||
| } | ||
| for resource in QuotaResource | ||
| } | ||
| return Response( | ||
| QuotaLimitsResponseSerializer( | ||
| { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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")), | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Medium: Cached spend bypasses token-scope authorization The cache is keyed only by resource and |
||
| 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: | ||
|
|
||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This project-read endpoint now returns organization-wide usage and limits for every quota resource.
TeamAndOrgViewSetMixinadmits any effective project member withproject:read, whereas the billing usage/spend endpoints requireIsOrganizationAdmin; thus a non-admin restricted to one project can call this endpoint (or use aproject:readpersonal key) to obtain the entire organization's current billing consumption and credit limits, including activity attributable to other projects.Prompt To Fix With AI
Severity: medium | Confidence: 97% | React with 👍 if useful or 👎 if not
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
intentional design choice; spend is relevant to users in posthog desktop app; can revisit later if needed