Skip to content
Draft
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
9 changes: 9 additions & 0 deletions src/sentry/seer/autofix/exceptions.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
from __future__ import annotations

from sentry.seer.credits import SeerCreditDenialReason


class NoSeerQuotaException(Exception):
"""Raised when an Autofix run cannot consume Seer quota."""


class SeerOutOfCreditsException(Exception):
"""Raised when the organization doesn't have enough Seer credits for an Autofix operation."""

def __init__(self, denial_reason: SeerCreditDenialReason | None = None) -> None:
super().__init__(denial_reason)
self.denial_reason = denial_reason


class IssueSummaryUnavailable(Exception):
"""Raised when an issue summary cannot be generated for an expected reason."""
Expand Down
16 changes: 9 additions & 7 deletions src/sentry/seer/autofix/feature/dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,12 @@
get_event_matched_project_repo_ids,
is_free_cohort_org,
)
from sentry.seer.credits import SeerFeature, SeerOperation, has_enough_seer_credits
from sentry.seer.credits import (
SeerFeature,
SeerOperation,
check_seer_operation,
get_denial_reason,
)
from sentry.seer.models.run import SeerAgentRun, SeerRun
from sentry.seer.models.seer_api_models import UNKNOWN_RUN_ID_FOR_GROUP, SeerPermissionError
from sentry.users.models.user import User
Expand Down Expand Up @@ -187,12 +192,9 @@ def check_autofix_billing(
"""
# Check credits for token-billed orgs.
if features.has("organizations:token-billed-seer-enabled", organization):
if features.has(
"organizations:seer-credit-checks", organization
) and not has_enough_seer_credits(
organization, seer_feature_for_referrer(referrer), operation
):
raise SeerOutOfCreditsException()
credits = check_seer_operation(organization, seer_feature_for_referrer(referrer), operation)
if not credits.has_enough_credits:
raise SeerOutOfCreditsException(get_denial_reason(credits))
return False

# Check quota for non-token-billed orgs once per run.
Expand Down
22 changes: 12 additions & 10 deletions src/sentry/seer/autofix/pr_iteration/manual_trigger.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,16 @@
from sentry.seer.autofix.pr_iteration.feedback_sources.base import ConsumeTriggerSource
from sentry.seer.autofix.pr_iteration.pause import PauseReason, get_pause_reason
from sentry.seer.autofix.pr_iteration.queue import enqueue_autofix_feedback
from sentry.seer.credits import SeerFeature, SeerOperation, can_run_seer_operation
from sentry.seer.credits import (
SeerFeature,
SeerOperation,
check_seer_operation,
get_denial_reason,
)
from sentry.seer.endpoints.utils import (
SEER_AUTOFIX_CONTINUE_OUT_OF_CREDITS_DETAIL,
SEER_OUT_OF_CREDITS_CODE,
SEER_PERMISSION_DENIED,
seer_out_of_credits_response,
)
from sentry.seer.models import SeerPermissionError
from sentry.tasks.seer.pr_iteration import trigger_consume_pr_iteration_feedback
Expand Down Expand Up @@ -87,15 +92,12 @@ def handle_ui_feedback(
)

# Check credits here, since the queued feedback is consumed after this request returns.
if not can_run_seer_operation(
credits = check_seer_operation(
group.organization, SeerFeature.AUTOFIX_WEB, SeerOperation.AUTOFIX_PR_ITERATION
):
return Response(
{
"detail": SEER_AUTOFIX_CONTINUE_OUT_OF_CREDITS_DETAIL,
"code": SEER_OUT_OF_CREDITS_CODE,
},
status=status.HTTP_402_PAYMENT_REQUIRED,
)
if not credits.has_enough_credits:
return seer_out_of_credits_response(
SEER_AUTOFIX_CONTINUE_OUT_OF_CREDITS_DETAIL, get_denial_reason(credits)
)

serialized_users = user_service.serialize_many(
Expand Down
25 changes: 15 additions & 10 deletions src/sentry/seer/credits.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from collections.abc import Callable
from enum import StrEnum
from typing import Literal

from sentry_protos.billing.v1.services.seer_feature_billing.v1.endpoint_check_seer_credits_pb2 import (
CheckSeerCreditsRequest,
Expand All @@ -15,6 +16,16 @@
OUT_OF_CREDITS = "out_of_credits"


SeerCreditDenialReason = Literal["not_enough_credits", "daily_cap_exceeded"]

# Unspecified and unknown reasons map to None, so a reason added to the proto
# before this mapping doesn't leak an enum name into the API.
_DENIAL_REASONS: dict[CheckSeerCreditsResponse.DenialReason.ValueType, SeerCreditDenialReason] = {
CheckSeerCreditsResponse.DENIAL_REASON_NOT_ENOUGH_CREDITS: "not_enough_credits",
CheckSeerCreditsResponse.DENIAL_REASON_DAILY_CAP_EXCEEDED: "daily_cap_exceeded",
}


class SeerOperation(StrEnum):
AGENT_CHAT_MESSAGE = "agent_chat_message"
AUTOFIX_ROOT_CAUSE = "autofix_root_cause"
Expand Down Expand Up @@ -66,16 +77,6 @@ def _check_seer_credits(
)


def has_enough_seer_credits(
organization: Organization, feature: SeerFeature, operation: SeerOperation
) -> bool:
"""
Checks whether the organization has enough Seer credits to run the operation,
for callers that already checked the credit check and token-billed flags.
"""
return _check_seer_credits(organization, feature, operation).has_enough_credits


def check_seer_operation(
organization: Organization, feature: SeerFeature, operation: SeerOperation
) -> CheckSeerCreditsResponse:
Expand All @@ -91,3 +92,7 @@ def can_run_seer_operation(
organization: Organization, feature: SeerFeature, operation: SeerOperation
) -> bool:
return check_seer_operation(organization, feature, operation).has_enough_credits


def get_denial_reason(response: CheckSeerCreditsResponse) -> SeerCreditDenialReason | None:
return _DENIAL_REASONS.get(response.denial_reason)
42 changes: 23 additions & 19 deletions src/sentry/seer/endpoints/group_ai_autofix.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,18 +84,19 @@
CodingAgentProviderType,
has_project_connected_repos,
)
from sentry.seer.credits import SeerCreditDenialReason
from sentry.seer.endpoints.organization_seer_onboarding_check import (
has_supported_scm_integration,
)
from sentry.seer.endpoints.utils import (
SEER_AUTOFIX_CONTINUE_OUT_OF_CREDITS_DETAIL,
SEER_AUTOFIX_START_OUT_OF_CREDITS_DETAIL,
SEER_OUT_OF_CREDITS_CODE,
SEER_OUT_OF_QUOTA_CODE,
SEER_PERMISSION_DENIED,
SeerPaymentRequiredCode,
get_seer_run,
resolve_seer_run,
seer_out_of_credits_response,
)
from sentry.seer.models import UNKNOWN_RUN_ID_FOR_GROUP, SeerPermissionError
from sentry.types.activity import ActivityType
Expand Down Expand Up @@ -164,6 +165,16 @@ class AutofixPaymentRequiredResponse(TypedDict):
"`seer_out_of_quota` when it has no Seer budget"
),
]
denialReason: NotRequired[
Annotated[
SeerCreditDenialReason | None,
ApiDescription(
"Set with `seer_out_of_credits`. `not_enough_credits` when the organization's "
"credit balance doesn't cover the operation, `daily_cap_exceeded` when it would "
"pass the organization's daily spend cap, null when no reason is known"
),
]
]


class GroupAutofixPermission(GroupAiPermission):
Expand Down Expand Up @@ -514,13 +525,9 @@ def post(
),
user=acting_user(request.user),
)
except SeerOutOfCreditsException:
return Response(
{
"detail": SEER_AUTOFIX_CONTINUE_OUT_OF_CREDITS_DETAIL,
"code": SEER_OUT_OF_CREDITS_CODE,
},
status=status.HTTP_402_PAYMENT_REQUIRED,
except SeerOutOfCreditsException as e:
return seer_out_of_credits_response(
SEER_AUTOFIX_CONTINUE_OUT_OF_CREDITS_DETAIL, e.denial_reason
)
except SeerPermissionError:
return Response(status=status.HTTP_404_NOT_FOUND)
Expand Down Expand Up @@ -653,17 +660,14 @@ def post(
{"detail": "No budget for Seer Autofix.", "code": SEER_OUT_OF_QUOTA_CODE},
status=status.HTTP_402_PAYMENT_REQUIRED,
)
except SeerOutOfCreditsException:
return Response(
{
"detail": (
SEER_AUTOFIX_START_OUT_OF_CREDITS_DETAIL
if resolved_run_id is None
else SEER_AUTOFIX_CONTINUE_OUT_OF_CREDITS_DETAIL
),
"code": SEER_OUT_OF_CREDITS_CODE,
},
status=status.HTTP_402_PAYMENT_REQUIRED,
except SeerOutOfCreditsException as e:
return seer_out_of_credits_response(
(
SEER_AUTOFIX_START_OUT_OF_CREDITS_DETAIL
if resolved_run_id is None
else SEER_AUTOFIX_CONTINUE_OUT_OF_CREDITS_DETAIL
),
e.denial_reason,
)
except SeerPermissionError as e:
if _is_unknown_run_id_error(e):
Expand Down
29 changes: 16 additions & 13 deletions src/sentry/seer/endpoints/organization_seer_agent_chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,18 @@
from sentry.seer.autofix.feature.models import SINGLE_RUN_FEATURE_ID
from sentry.seer.autofix.prompt_redaction import redact_autofix_prompts
from sentry.seer.autofix.single_run import is_single_run
from sentry.seer.credits import SeerFeature, SeerOperation, can_run_seer_operation
from sentry.seer.credits import (
SeerFeature,
SeerOperation,
check_seer_operation,
get_denial_reason,
)
from sentry.seer.endpoints.group_ai_autofix import RUN_IN_FLIGHT_CODE
from sentry.seer.endpoints.utils import (
SEER_AGENT_CHAT_OUT_OF_CREDITS_DETAIL,
SEER_OUT_OF_CREDITS_CODE,
ResolvedSeerRun,
resolve_seer_run,
seer_out_of_credits_response,
)
from sentry.seer.models import SeerApiError, SeerPermissionError
from sentry.seer.models.run import SeerAgentRun
Expand Down Expand Up @@ -276,11 +281,8 @@ def _maybe_reply_to_single_run(
can_publish=request.access.has_any_project_scope(project, PUBLISH_SCOPES),
referrer=AutofixReferrer.SEER_EXPLORER,
)
except SeerOutOfCreditsException:
return Response(
{"detail": SEER_AGENT_CHAT_OUT_OF_CREDITS_DETAIL, "code": SEER_OUT_OF_CREDITS_CODE},
status=402,
)
except SeerOutOfCreditsException as e:
return seer_out_of_credits_response(SEER_AGENT_CHAT_OUT_OF_CREDITS_DETAIL, e.denial_reason)
except SeerApiError as e:
if e.status == 404:
return Response({"session": None}, status=404)
Expand Down Expand Up @@ -452,13 +454,14 @@ def post(
if not has_access and not is_dashboard_generate_run:
raise PermissionDenied(error)
# Do not bill for dashboard generation.
if not is_dashboard_generate_run and not can_run_seer_operation(
organization, SeerFeature.SEER_AGENT, SeerOperation.AGENT_CHAT_MESSAGE
):
return Response(
{"detail": SEER_AGENT_CHAT_OUT_OF_CREDITS_DETAIL, "code": SEER_OUT_OF_CREDITS_CODE},
status=402,
if not is_dashboard_generate_run:
credits = check_seer_operation(
organization, SeerFeature.SEER_AGENT, SeerOperation.AGENT_CHAT_MESSAGE
)
if not credits.has_enough_credits:
return seer_out_of_credits_response(
SEER_AGENT_CHAT_OUT_OF_CREDITS_DETAIL, get_denial_reason(credits)
)

try:
# Seer only exposes write_pull_request when coding and bash mode are also on.
Expand Down
19 changes: 7 additions & 12 deletions src/sentry/seer/endpoints/organization_seer_credit_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,21 @@
from rest_framework import serializers
from rest_framework.request import Request
from rest_framework.response import Response
from sentry_protos.billing.v1.services.seer_feature_billing.v1.endpoint_check_seer_credits_pb2 import (
CheckSeerCreditsResponse,
)

from sentry.api.api_owners import ApiOwner
from sentry.api.api_publish_status import ApiPublishStatus
from sentry.api.base import cell_silo_endpoint
from sentry.api.bases.organization import OrganizationEndpoint
from sentry.models.organization import Organization
from sentry.ratelimits.config import RateLimitConfig
from sentry.seer.credits import SeerFeature, SeerOperation, check_seer_operation
from sentry.seer.credits import (
SeerFeature,
SeerOperation,
check_seer_operation,
get_denial_reason,
)
from sentry.types.ratelimit import RateLimit, RateLimitCategory

# Unspecified and unknown reasons map to None, so a reason added to the proto
# before this mapping doesn't leak an enum name into the API.
_DENIAL_REASONS: dict[CheckSeerCreditsResponse.DenialReason.ValueType, str] = {
CheckSeerCreditsResponse.DENIAL_REASON_NOT_ENOUGH_CREDITS: "not_enough_credits",
CheckSeerCreditsResponse.DENIAL_REASON_DAILY_CAP_EXCEEDED: "daily_cap_exceeded",
}


class SeerCreditCheckSerializer(serializers.Serializer[dict[str, str]]):
operation = serializers.ChoiceField(choices=[operation.value for operation in SeerOperation])
Expand Down Expand Up @@ -66,6 +61,6 @@ def get(self, request: Request, organization: Organization) -> Response:
return Response(
{
"hasEnoughCredits": credits.has_enough_credits,
"denialReason": _DENIAL_REASONS.get(credits.denial_reason),
"denialReason": get_denial_reason(credits),
}
)
10 changes: 10 additions & 0 deletions src/sentry/seer/endpoints/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from rest_framework.response import Response

from sentry.api.bases.organization import OrganizationPermission
from sentry.seer.credits import SeerCreditDenialReason
from sentry.seer.models.run import SeerRun, SeerRunMirrorStatus
from sentry.utils.numbers import validate_bigint

Expand All @@ -34,6 +35,15 @@
)


def seer_out_of_credits_response(
detail: str, denial_reason: SeerCreditDenialReason | None
) -> Response:
return Response(
{"detail": detail, "code": SEER_OUT_OF_CREDITS_CODE, "denialReason": denial_reason},
status=status.HTTP_402_PAYMENT_REQUIRED,
)


class OrganizationTraceExplorerAIPermission(OrganizationPermission):
scope_map = {
"GET": ["org:read"],
Expand Down
8 changes: 6 additions & 2 deletions tests/sentry/seer/autofix/feature/test_dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -346,7 +346,10 @@ def test_token_billed_without_credit_checks_skips_quota_and_usage_recording(
@with_feature(["organizations:seer-credit-checks", "organizations:token-billed-seer-enabled"])
@patch(
"sentry.seer_credit.backend.check_seer_credits",
return_value=CheckSeerCreditsResponse(has_enough_credits=False),
return_value=CheckSeerCreditsResponse(
has_enough_credits=False,
denial_reason=CheckSeerCreditsResponse.DENIAL_REASON_DAILY_CAP_EXCEEDED,
),
)
def test_raises_when_out_of_credits(self, mock_check_seer_credits: MagicMock) -> None:
with (
Expand All @@ -355,7 +358,7 @@ def test_raises_when_out_of_credits(self, mock_check_seer_credits: MagicMock) ->
):
mock_quotas.backend.check_seer_quota.return_value = True

with pytest.raises(SeerOutOfCreditsException):
with pytest.raises(SeerOutOfCreditsException) as excinfo:
trigger_autofix_feature(
self.group,
AutofixFeatureArgs(
Expand All @@ -365,6 +368,7 @@ def test_raises_when_out_of_credits(self, mock_check_seer_credits: MagicMock) ->
),
)

assert excinfo.value.denial_reason == "daily_cap_exceeded"
MockClient.return_value.start_feature_run.assert_not_called()
mock_quotas.backend.check_seer_quota.assert_not_called()
mock_quotas.backend.record_seer_run.assert_not_called()
Expand Down
Loading
Loading