Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions ee/api/quota_limits.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
"""Expose a team's quota-limit state.

Backs the LLM gateway's `QuotaResolver`, which forwards the caller's auth
header here to learn whether a given team is currently over its AI credits
quota. Project-nested so org membership and token `scoped_teams`/
`scoped_organizations` enforcement come from the standard
`TeamAndOrgViewSetMixin` permission chain — see
`posthog.permissions.APIScopePermission.check_team_and_org_permissions`.
"""

from __future__ import annotations

from typing import Any

from drf_spectacular.utils import extend_schema
from rest_framework import serializers, viewsets
from rest_framework.request import Request
from rest_framework.response import Response

from posthog.api.routing import TeamAndOrgViewSetMixin

from ee.billing.quota_limiting import QuotaLimitingCaches, QuotaResource, is_team_limited


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.",
)


class QuotaLimitsResponseSerializer(serializers.Serializer):
limited = serializers.DictField(
child=QuotaResourceLimitSerializer(),
help_text=(
"Per-resource limit state keyed by `QuotaResource` value. "
"Currently only `ai_credits` is reported; additional resources may be added."
),
)


@extend_schema(tags=["quota_limits"])
class QuotaLimitsViewSet(TeamAndOrgViewSetMixin, viewsets.ViewSet):
"""Read-only view of a team's quota-limit state."""

scope_object = "project"
required_scopes = ["project:read"]
http_method_names = ["get", "head", "options"]

@extend_schema(
summary="Get a team's quota-limit state",
description=(
"Return the current quota-limit state for the team identified in the URL, "
"keyed by `QuotaResource` value. Used by the LLM gateway to gate billable "
"products on AI credits exhaustion."
),
responses={200: QuotaLimitsResponseSerializer},
)
def list(self, request: Request, *args: Any, **kwargs: Any) -> Response:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this should be general given the endpoints registration, lets show them quota limiting status for all products?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is technically only data that should be available to admins since it's directed off of billing data, but given we're just exposing is limited or not this seems fine to be available to anyone with project:read imo

cc @PostHog/team-billing in case one of them disagrees, but you can assume not

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

limited = {
resource.value: {
"limited": is_team_limited(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what does this function call? is it a clickhouse query, or hitting redis? (I think redis in which case great)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yup, redis 🚀

self.team.api_token,
resource,
QuotaLimitingCaches.QUOTA_LIMITER_CACHE_KEY,
),
}
for resource in QuotaResource
}
return Response(QuotaLimitsResponseSerializer({"limited": limited}).data)
164 changes: 164 additions & 0 deletions ee/api/test/test_quota_limits.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
from __future__ import annotations

from posthog.test.base import APIBaseTest

from rest_framework import status

from posthog.models.organization import Organization
from posthog.models.personal_api_key import PersonalAPIKey
from posthog.models.team import Team
from posthog.models.utils import generate_random_token_personal, hash_key_value

from ee.billing.quota_limiting import (
QuotaLimitingCaches,
QuotaResource,
add_limited_team_tokens,
replace_limited_team_tokens,
)


def _clear_ai_credits_limits() -> None:
replace_limited_team_tokens(QuotaResource.AI_CREDITS, {}, QuotaLimitingCaches.QUOTA_LIMITER_CACHE_KEY)


class TestQuotaLimitsAPI(APIBaseTest):
def setUp(self) -> None:
super().setUp()
_clear_ai_credits_limits()

def tearDown(self) -> None:
_clear_ai_credits_limits()
super().tearDown()

def _url(self, team_id: int | None = None) -> str:
return f"/api/projects/{team_id if team_id is not None else self.team.pk}/quota_limits/"

def _set_ai_credits_limit(self, team_api_token: str, expires_at: int) -> None:
add_limited_team_tokens(
QuotaResource.AI_CREDITS,
{team_api_token: expires_at},
QuotaLimitingCaches.QUOTA_LIMITER_CACHE_KEY,
)

def test_unauthenticated_returns_401_or_403(self) -> None:
self.client.logout()
response = self.client.get(self._url())
# DRF returns 401 when no creds are presented and an authenticator that supports
# a WWW-Authenticate challenge is configured; otherwise it returns 403. Either is
# an auth failure — we only care that the endpoint refuses unauthenticated reads.
self.assertIn(response.status_code, (status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN))

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})

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})

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})

def test_personal_api_key_auth_works(self) -> None:
self.client.logout()
raw_key = generate_random_token_personal()
PersonalAPIKey.objects.create(
label="quota_limits-test",
user=self.user,
secure_value=hash_key_value(raw_key),
scopes=["project:read"],
)

self._set_ai_credits_limit(self.team.api_token, 9_999_999_999)

response = self.client.get(
self._url(),
headers={"authorization": f"Bearer {raw_key}"},
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.json()["limited"]["ai_credits"], {"limited": True})

def test_user_not_in_teams_org_is_forbidden(self) -> None:
other_org = Organization.objects.create(name="other-org")
other_team = Team.objects.create(organization=other_org, name="other-team")

response = self.client.get(self._url(other_team.pk))
# The caller is logged in to a team in a different org — TeamMemberAccessPermission
# rejects with 403 (or 404 if the queryset can't see the team at all).
self.assertIn(response.status_code, (status.HTTP_403_FORBIDDEN, status.HTTP_404_NOT_FOUND))

def test_personal_api_key_scoped_to_a_different_team_is_forbidden(self) -> None:
# Caller has access to both teams via membership, but the token is scoped to
# `other_team` only — the standalone-endpoint design would have missed this and
# leaked the other team's state.
other_team = Team.objects.create(organization=self.organization, name="other-team")
self.client.logout()
raw_key = generate_random_token_personal()
PersonalAPIKey.objects.create(
label="quota_limits-test",
user=self.user,
secure_value=hash_key_value(raw_key),
scopes=["project:read"],
scoped_teams=[other_team.pk],
)

response = self.client.get(
self._url(),
headers={"authorization": f"Bearer {raw_key}"},
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)

def test_personal_api_key_missing_required_scope_is_forbidden(self) -> None:
# A token with only `feature_flag:read` shouldn't be able to read quota state.
self.client.logout()
raw_key = generate_random_token_personal()
PersonalAPIKey.objects.create(
label="quota_limits-test",
user=self.user,
secure_value=hash_key_value(raw_key),
scopes=["feature_flag:read"],
)

response = self.client.get(
self._url(),
headers={"authorization": f"Bearer {raw_key}"},
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)

def test_response_includes_every_quota_resource(self) -> None:
# Limiting one resource must not hide the unlimited state of the rest.
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)
limited = response.json()["limited"]
expected_keys = {resource.value for resource in QuotaResource}
self.assertEqual(set(limited.keys()), expected_keys)
self.assertTrue(limited["ai_credits"]["limited"])
for resource in QuotaResource:
if resource is QuotaResource.AI_CREDITS:
continue
self.assertFalse(limited[resource.value]["limited"], resource.value)

def test_multi_team_user_gets_per_team_answers(self) -> None:
# Same user belongs to two teams in their org; each team's quota is independent.
# This is the regression that "me" couldn't model — `user.team` (current team)
# picked one arbitrary answer for users in multiple teams.
other_team = Team.objects.create(organization=self.organization, name="other-team")
self._set_ai_credits_limit(self.team.api_token, 9_999_999_999)
# other_team's token deliberately not limited

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})
9 changes: 9 additions & 0 deletions posthog/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@
)
from products.web_analytics.backend.api.web_analytics_filter_preset import WebAnalyticsFilterPresetViewSet

from ee.api.quota_limits import QuotaLimitsViewSet
from ee.api.session_summaries import SessionGroupSummaryViewSet
from ee.api.vercel import vercel_installation, vercel_product, vercel_proxy, vercel_resource

Expand Down Expand Up @@ -371,6 +372,14 @@ def register_grandfathered_environment_nested_viewset(
# Seats (proxied to billing service)
router.register(r"seats", seats.SeatViewSet, "seats")

# Quota limits (project-scoped — backs the LLM gateway's QuotaResolver)
projects_router.register(
r"quota_limits",
QuotaLimitsViewSet,
"project_quota_limits",
["team_id"],
)

projects_router.register(r"surveys", survey.SurveyViewSet, "project_surveys", ["project_id"])
projects_router.register(r"product_tours", ProductTourViewSet, "project_product_tours", ["project_id"])
projects_router.register(
Expand Down
1 change: 1 addition & 0 deletions posthog/llm/gateway_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"llm_gateway",
"posthog_code",
"background_agents",
"slack_app_routing",
"wizard",
"django",
"growth",
Expand Down
2 changes: 2 additions & 0 deletions posthog/temporal/ai/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
create_posthog_code_routing_rule_activity,
create_posthog_code_task_for_repo_activity,
discover_posthog_code_repository_via_agent_activity,
enforce_posthog_code_billing_quota_activity,
forward_posthog_code_followup_activity,
handle_posthog_code_rules_command_activity,
post_posthog_code_internal_error_activity,
Expand Down Expand Up @@ -68,6 +69,7 @@
process_research_agent_activity,
summarize_llm_traces_activity,
process_slack_conversation_activity,
enforce_posthog_code_billing_quota_activity,
resolve_posthog_code_slack_user_activity,
handle_posthog_code_rules_command_activity,
collect_posthog_code_thread_messages_activity,
Expand Down
Loading
Loading