diff --git a/src/sentry/seer/autofix/autofix_agent.py b/src/sentry/seer/autofix/autofix_agent.py index b02c16b33ab1..af99e4c3e05f 100644 --- a/src/sentry/seer/autofix/autofix_agent.py +++ b/src/sentry/seer/autofix/autofix_agent.py @@ -9,7 +9,7 @@ from scm.types import GetBranchProtocol, GetRepositoryProtocol from sentry_sdk import traces -from sentry import features, quotas +from sentry import features from sentry.analytics.events.autofix_events import ( AiAutofixAgentHandoffEvent, AiAutofixCodeChangesCompletedEvent, @@ -23,7 +23,7 @@ AiAutofixSolutionCompletedEvent, AiAutofixSolutionStartedEvent, ) -from sentry.constants import ENABLE_SEER_CODING_DEFAULT, DataCategory +from sentry.constants import ENABLE_SEER_CODING_DEFAULT from sentry.integrations.services.integration import integration_service from sentry.models.group import Group from sentry.seer.agent.client import SeerAgentClient @@ -31,10 +31,11 @@ from sentry.seer.autofix.analytics import record_autofix_event from sentry.seer.autofix.commit_author import SeerCommitAuthor from sentry.seer.autofix.constants import AutofixReferrer -from sentry.seer.autofix.exceptions import NoSeerQuotaException +from sentry.seer.autofix.exceptions import NoSeerQuotaException, SeerOutOfCreditsException from sentry.seer.autofix.feature.dispatch import ( AutofixFeatureArgs, autofix_run_targets_other_issue, + check_autofix_billing, require_autofix_run_for_group, trigger_autofix_feature, ) @@ -63,6 +64,7 @@ is_free_cohort_org, read_preference_from_sentry_db, ) +from sentry.seer.credits import SeerOperation from sentry.seer.entrypoints.operator import ( SeerActivityAttribution, SeerAutofixOperator, @@ -461,20 +463,6 @@ def trigger_autofix_agent( """ outcome = "success" try: - # check billing quota for triggering a new autofix run - # Free cohort orgs bypass quota only when called from agentic triage - # (allow_free_cohort=True). The API endpoint never sets this flag, - # so manual triggers still require quota. - if run_id is None: - skip_quota_check = allow_free_cohort and is_free_cohort_org(group.organization) - if not skip_quota_check: - has_budget: bool = quotas.backend.check_seer_quota( - org_id=group.organization.id, - data_category=DataCategory.SEER_AUTOFIX, - ) - if not has_budget: - raise NoSeerQuotaException() - if run_id is not None: _assert_existing_run_belongs_to_group(group, run_id) @@ -548,7 +536,12 @@ def trigger_autofix_agent( actor_user_id=actor_user_id, ) return feature_run - except (NoSeerQuotaException, SeerPermissionError, PrIterationNoPullRequestException): + except ( + NoSeerQuotaException, + SeerOutOfCreditsException, + SeerPermissionError, + PrIterationNoPullRequestException, + ): outcome = "declined" raise except BaseException: @@ -839,6 +832,8 @@ def trigger_push_changes( ): raise PermissionDenied("Code generation is disabled for this organization") + check_autofix_billing(group.organization, SeerOperation.AUTOFIX_OPEN_PR, is_new_run=False) + client = get_autofix_agent_client(group, user=user) if state is None: diff --git a/src/sentry/seer/autofix/exceptions.py b/src/sentry/seer/autofix/exceptions.py index 74332026f4dc..7ee853708745 100644 --- a/src/sentry/seer/autofix/exceptions.py +++ b/src/sentry/seer/autofix/exceptions.py @@ -2,6 +2,10 @@ 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.""" + + class IssueSummaryUnavailable(Exception): """Raised when an issue summary cannot be generated for an expected reason.""" diff --git a/src/sentry/seer/autofix/feature/dispatch.py b/src/sentry/seer/autofix/feature/dispatch.py index da52fd498a5a..be25d1b1c476 100644 --- a/src/sentry/seer/autofix/feature/dispatch.py +++ b/src/sentry/seer/autofix/feature/dispatch.py @@ -13,6 +13,7 @@ DataCategory, ) from sentry.models.group import Group +from sentry.models.organization import Organization from sentry.models.project import Project from sentry.seer.agent.client import SeerAgentClient from sentry.seer.agent.client_utils import ( @@ -22,7 +23,7 @@ ) from sentry.seer.agent.on_completion_hook import extract_hook_definition from sentry.seer.autofix.constants import AutofixReferrer -from sentry.seer.autofix.exceptions import NoSeerQuotaException +from sentry.seer.autofix.exceptions import NoSeerQuotaException, SeerOutOfCreditsException from sentry.seer.autofix.feature.models import ( FEATURE_ID, LEGACY_FEATURE_ID, @@ -41,6 +42,7 @@ get_automation_handoff, is_free_cohort_org, ) +from sentry.seer.credits import SeerOperation, has_enough_seer_credits 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 @@ -148,6 +150,39 @@ def _org_allows_coding(group: Group) -> bool: ) +def check_autofix_billing( + organization: Organization, + operation: SeerOperation, + *, + is_new_run: bool, + skip_quota_for_free_cohort: bool = False, +) -> bool: + """ + Raise if the organization can't pay for the operation. Token-billed orgs are + checked for credits, and everyone else is checked for quota once per run. + Returns whether the operation uses quota. + """ + # 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, operation): + raise SeerOutOfCreditsException() + return False + + # Check quota for non-token-billed orgs once per run. + # Free cohort orgs skip quota when the caller passes skip_quota_for_free_cohort=True. + # Only agentic triage does, and it is not exposed via API. + skips_quota = skip_quota_for_free_cohort and is_free_cohort_org(organization) + uses_quota = is_new_run and not skips_quota + if uses_quota and not quotas.backend.check_seer_quota( + org_id=organization.id, + data_category=DataCategory.SEER_AUTOFIX, + ): + raise NoSeerQuotaException() + return uses_quota + + def continue_single_run( agent_run: SeerAgentRun, group: Group, @@ -159,6 +194,12 @@ def continue_single_run( ) -> SeerRun: from sentry.seer.autofix.on_completion_hook import AutofixOnCompletionHook + check_autofix_billing( + group.organization, + SeerOperation.AUTOFIX_SINGLE_RUN_REPLY, + is_new_run=False, + ) + enable_coding = _org_allows_coding(group) open_pr = enable_coding and can_publish payload = AutofixSingleRunFeaturePayload( @@ -209,24 +250,36 @@ def trigger_autofix_feature( from sentry.seer.autofix.on_completion_hook import AutofixOnCompletionHook is_new_run = args.existing_run_id is None - # Free cohort orgs bypass quota only when called from agentic triage - # (allow_free_cohort=True). Not exposed via the API. - skip_quota = is_new_run and args.allow_free_cohort and is_free_cohort_org(group.organization) - if is_new_run and not skip_quota: - has_budget: bool = quotas.backend.check_seer_quota( - org_id=group.organization.id, - data_category=DataCategory.SEER_AUTOFIX, + try: + uses_quota = check_autofix_billing( + group.organization, + args.step.seer_operation, + is_new_run=is_new_run, + # Free cohort orgs bypass quota only when called from agentic triage + # (allow_free_cohort=True). Not exposed via the API. + skip_quota_for_free_cohort=args.allow_free_cohort, + ) + except NoSeerQuotaException: + logger.warning( + "autofix_feature.dispatch.quota_denied", + extra={ + "group_id": group.id, + "organization_id": group.organization.id, + "referrer": args.referrer.value, + }, + ) + raise + except SeerOutOfCreditsException: + logger.info( + "autofix_feature.dispatch.out_of_credits", + extra={ + "group_id": group.id, + "organization_id": group.organization.id, + "operation": args.step.seer_operation.value, + "referrer": args.referrer.value, + }, ) - if not has_budget: - logger.warning( - "autofix_feature.dispatch.quota_denied", - extra={ - "group_id": group.id, - "organization_id": group.organization.id, - "referrer": args.referrer.value, - }, - ) - raise NoSeerQuotaException() + raise single_run = should_use_single_run( group, @@ -342,7 +395,7 @@ def trigger_autofix_feature( else: raise Exception("Unhandled run_id branch, this should never happen") - if is_new_run and not skip_quota: + if uses_quota: quotas.backend.record_seer_run( group.organization.id, group.project.id, DataCategory.SEER_AUTOFIX ) diff --git a/src/sentry/seer/autofix/steps.py b/src/sentry/seer/autofix/steps.py index b35a0d86ebea..8ec531cfde9e 100644 --- a/src/sentry/seer/autofix/steps.py +++ b/src/sentry/seer/autofix/steps.py @@ -3,6 +3,7 @@ from enum import StrEnum from sentry.seer.autofix.utils import AutofixStoppingPoint +from sentry.seer.credits import SeerOperation class AutofixStep(StrEnum): @@ -13,6 +14,19 @@ class AutofixStep(StrEnum): CODE_CHANGES = "code_changes" PR_ITERATION = "pr_iteration" + @property + def seer_operation(self) -> SeerOperation: + """The Seer credit check operation corresponding to this step.""" + match self: + case AutofixStep.ROOT_CAUSE: + return SeerOperation.AUTOFIX_ROOT_CAUSE + case AutofixStep.SOLUTION: + return SeerOperation.AUTOFIX_SOLUTION + case AutofixStep.CODE_CHANGES: + return SeerOperation.AUTOFIX_CODE_CHANGES + case AutofixStep.PR_ITERATION: + return SeerOperation.AUTOFIX_PR_ITERATION + @staticmethod def from_autofix_stopping_point( autofix_stopping_point: AutofixStoppingPoint, diff --git a/src/sentry/seer/credits.py b/src/sentry/seer/credits.py index ac40567af5cd..d6cff3e7f32d 100644 --- a/src/sentry/seer/credits.py +++ b/src/sentry/seer/credits.py @@ -19,6 +19,7 @@ class SeerOperation(StrEnum): AUTOFIX_CODE_CHANGES = "autofix_code_changes" AUTOFIX_OPEN_PR = "autofix_open_pr" AUTOFIX_PR_ITERATION = "autofix_pr_iteration" + AUTOFIX_SINGLE_RUN_REPLY = "autofix_single_run_reply" class SeerFeature(StrEnum): @@ -57,16 +58,17 @@ class SeerOperationConfig: SeerOperation.AUTOFIX_PR_ITERATION: SeerOperationConfig( SeerFeature.AUTOFIX, lambda: PLACEHOLDER_CREDIT_ESTIMATE ), + SeerOperation.AUTOFIX_SINGLE_RUN_REPLY: SeerOperationConfig( + SeerFeature.AUTOFIX, lambda: PLACEHOLDER_CREDIT_ESTIMATE + ), } -def can_run_seer_operation(organization: Organization, operation: SeerOperation) -> bool: - """Whether the organization is token-billed and has enough Seer credits to run the operation.""" - if not features.has("organizations:seer-credit-checks", organization) or not features.has( - "organizations:token-billed-seer-enabled", organization - ): - return True - +def has_enough_seer_credits(organization: Organization, 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. + """ config = SEER_OPERATIONS[operation] response = seer_credit.backend.check_seer_credits( CheckSeerCreditsRequest( @@ -77,3 +79,12 @@ def can_run_seer_operation(organization: Organization, operation: SeerOperation) ) ) return response.has_enough_credits + + +def can_run_seer_operation(organization: Organization, operation: SeerOperation) -> bool: + """Checks whether the organization is token-billed and has enough Seer credits to run the operation.""" + if not features.has("organizations:seer-credit-checks", organization) or not features.has( + "organizations:token-billed-seer-enabled", organization + ): + return True + return has_enough_seer_credits(organization, operation) diff --git a/tests/sentry/seer/autofix/feature/test_dispatch.py b/tests/sentry/seer/autofix/feature/test_dispatch.py index 0c2e431dcb5b..62f8d3c9e03c 100644 --- a/tests/sentry/seer/autofix/feature/test_dispatch.py +++ b/tests/sentry/seer/autofix/feature/test_dispatch.py @@ -2,10 +2,13 @@ from unittest.mock import MagicMock, patch import pytest +from sentry_protos.billing.v1.services.seer_feature_billing.v1.endpoint_check_seer_credits_pb2 import ( + CheckSeerCreditsResponse, +) from sentry.constants import DataCategory from sentry.seer.autofix.constants import AutofixReferrer -from sentry.seer.autofix.exceptions import NoSeerQuotaException +from sentry.seer.autofix.exceptions import NoSeerQuotaException, SeerOutOfCreditsException from sentry.seer.autofix.feature.dispatch import ( AutofixFeatureArgs, continue_single_run, @@ -25,6 +28,7 @@ from sentry.seer.autofix.utils import AutofixStoppingPoint from sentry.seer.models import SeerPermissionError from sentry.testutils.cases import TestCase +from sentry.testutils.helpers.features import with_feature from sentry.testutils.pytest.fixtures import django_db_all @@ -279,6 +283,110 @@ def test_free_cohort_skips_quota_check_and_usage_recording(self) -> None: mock_quotas.backend.check_seer_quota.assert_not_called() mock_quotas.backend.record_seer_run.assert_not_called() + def test_non_free_cohort_org_checks_quota_and_records_usage(self) -> None: + fake_run = self.create_seer_run(organization=self.organization, type="feature_run") + + with ( + patch("sentry.seer.autofix.feature.dispatch.SeerAgentClient") as MockClient, + patch("sentry.seer.autofix.feature.dispatch.quotas") as mock_quotas, + patch("sentry.seer.autofix.feature.dispatch.is_free_cohort_org", return_value=False), + ): + mock_quotas.backend.check_seer_quota.return_value = True + MockClient.return_value.start_feature_run.return_value = fake_run + + run = trigger_autofix_feature( + self.group, + AutofixFeatureArgs( + referrer=AutofixReferrer.AGENTIC_TRIAGE, + step=AutofixStep.ROOT_CAUSE, + step_args=RCAStepArgs(), + allow_free_cohort=True, + ), + ) + + assert run is fake_run + mock_quotas.backend.check_seer_quota.assert_called_once() + mock_quotas.backend.record_seer_run.assert_called_once() + + @with_feature("organizations:token-billed-seer-enabled") + @patch("sentry.seer_credit.backend.check_seer_credits") + def test_token_billed_without_credit_checks_skips_quota_and_usage_recording( + self, mock_check_seer_credits: MagicMock + ) -> None: + fake_run = self.create_seer_run(organization=self.organization, type="feature_run") + + with ( + patch("sentry.seer.autofix.feature.dispatch.SeerAgentClient") as MockClient, + patch("sentry.seer.autofix.feature.dispatch.quotas") as mock_quotas, + ): + MockClient.return_value.start_feature_run.return_value = fake_run + + run = trigger_autofix_feature( + self.group, + AutofixFeatureArgs( + referrer=AutofixReferrer.GROUP_AUTOFIX_ENDPOINT, + step=AutofixStep.ROOT_CAUSE, + step_args=RCAStepArgs(), + ), + ) + + assert run is fake_run + mock_check_seer_credits.assert_not_called() + mock_quotas.backend.check_seer_quota.assert_not_called() + mock_quotas.backend.record_seer_run.assert_not_called() + + @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), + ) + def test_raises_when_out_of_credits(self, mock_check_seer_credits: MagicMock) -> None: + with ( + patch("sentry.seer.autofix.feature.dispatch.SeerAgentClient") as MockClient, + patch("sentry.seer.autofix.feature.dispatch.quotas") as mock_quotas, + ): + mock_quotas.backend.check_seer_quota.return_value = True + + with pytest.raises(SeerOutOfCreditsException): + trigger_autofix_feature( + self.group, + AutofixFeatureArgs( + referrer=AutofixReferrer.GROUP_AUTOFIX_ENDPOINT, + step=AutofixStep.ROOT_CAUSE, + step_args=RCAStepArgs(), + ), + ) + + 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() + + @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), + ) + def test_raises_when_out_of_credits_for_a_continued_step( + self, mock_check_seer_credits: MagicMock + ) -> None: + """Unlike quota, credits are checked for every step, not only new runs.""" + with ( + patch("sentry.seer.autofix.feature.dispatch.SeerAgentClient") as MockClient, + patch("sentry.seer.autofix.feature.dispatch.quotas"), + ): + with pytest.raises(SeerOutOfCreditsException): + trigger_autofix_feature( + self.group, + AutofixFeatureArgs( + referrer=AutofixReferrer.GROUP_AUTOFIX_ENDPOINT, + step=AutofixStep.SOLUTION, + existing_run_id=123, + step_args=SolutionStepArgs(), + ), + ) + + MockClient.return_value.continue_feature_run.assert_not_called() + def test_allows_async_dispatch(self) -> None: fake_run = self.create_seer_run(organization=self.organization, type="feature_run") @@ -608,3 +716,24 @@ def test_respects_org_coding_opt_out(self) -> None: assert kwargs["agent_run_options"]["enable_coding"] is False assert kwargs["agent_run_options"]["enable_write_pull_request"] is False assert kwargs["payload"]["pr_internal_settings"] is None + + @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), + ) + def test_raises_when_out_of_credits(self, mock_check_seer_credits: MagicMock) -> None: + with ( + patch("sentry.seer.autofix.feature.dispatch.SeerAgentClient") as mock_client_cls, + pytest.raises(SeerOutOfCreditsException), + ): + continue_single_run( + self.agent_run, + self.group, + "make a code fix", + user=self.user, + can_publish=True, + referrer=AutofixReferrer.SEER_EXPLORER, + ) + + mock_client_cls.return_value.continue_feature_run.assert_not_called() diff --git a/tests/sentry/seer/autofix/test_autofix_agent.py b/tests/sentry/seer/autofix/test_autofix_agent.py index 477b8bf89ffa..555ef5924e20 100644 --- a/tests/sentry/seer/autofix/test_autofix_agent.py +++ b/tests/sentry/seer/autofix/test_autofix_agent.py @@ -4,9 +4,11 @@ import pytest from django.test import override_settings from rest_framework.exceptions import PermissionDenied +from sentry_protos.billing.v1.services.seer_feature_billing.v1.endpoint_check_seer_credits_pb2 import ( + CheckSeerCreditsResponse, +) from sentry.analytics.events.autofix_events import AiAutofixSolutionCompletedEvent -from sentry.constants import DataCategory from sentry.models.activity import Activity from sentry.models.group import Group from sentry.seer.agent.client_models import ( @@ -27,7 +29,7 @@ ) from sentry.seer.autofix.commit_author import SeerCommitAuthor from sentry.seer.autofix.constants import AutofixReferrer, SeerAutomationSource -from sentry.seer.autofix.exceptions import NoSeerQuotaException +from sentry.seer.autofix.exceptions import NoSeerQuotaException, SeerOutOfCreditsException from sentry.seer.autofix.feature.models import SINGLE_RUN_FEATURE_ID from sentry.seer.autofix.issue_summary import referrer_map from sentry.seer.autofix.pr_iteration.feedback import Feedback @@ -38,6 +40,7 @@ from sentry.seer.models.run import SeerAgentRun, SeerRun from sentry.sentry_apps.utils.webhooks import SeerActionType from sentry.testutils.cases import TestCase +from sentry.testutils.helpers.features import with_feature from sentry.types.activity import ActivityType from sentry.utils import json @@ -704,22 +707,6 @@ def test_pr_iteration_requires_existing_pr( mock_feature.assert_not_called() - @patch("sentry.seer.autofix.autofix_agent.trigger_autofix_feature") - @patch("sentry.quotas.backend.check_seer_quota", return_value=False) - def test_when_no_quota(self, mock_check_quota: MagicMock, mock_feature: MagicMock) -> None: - with pytest.raises(NoSeerQuotaException): - trigger_autofix_agent( - group=self.group, - step=AutofixStep.CODE_CHANGES, - referrer=AutofixReferrer.UNKNOWN, - run_id=None, - ) - mock_check_quota.assert_called_once_with( - org_id=self.group.organization.id, - data_category=DataCategory.SEER_AUTOFIX, - ) - mock_feature.assert_not_called() - @patch("sentry.quotas.backend.check_seer_quota", return_value=True) @patch("sentry.seer.autofix.autofix_agent.broadcast_webhooks_for_organization.delay") @patch("sentry.seer.autofix.autofix_agent.trigger_autofix_feature") @@ -732,7 +719,7 @@ def test_free_cohort_skips_quota_check( with ( patch("sentry.seer.autofix.autofix_agent.features.has", return_value=False), - patch("sentry.seer.autofix.autofix_agent.is_free_cohort_org", return_value=True), + patch("sentry.seer.autofix.feature.dispatch.is_free_cohort_org", return_value=True), ): trigger_autofix_agent( group=self.group, @@ -744,29 +731,6 @@ def test_free_cohort_skips_quota_check( mock_check_quota.assert_not_called() assert mock_feature.call_args.args[1].allow_free_cohort is True - @patch("sentry.quotas.backend.check_seer_quota", return_value=True) - @patch("sentry.seer.autofix.autofix_agent.broadcast_webhooks_for_organization.delay") - @patch("sentry.seer.autofix.autofix_agent.trigger_autofix_feature") - def test_allow_free_cohort_still_checks_quota_for_non_cohort_org( - self, mock_feature: MagicMock, mock_broadcast: MagicMock, mock_check_quota: MagicMock - ) -> None: - mock_feature.return_value = self.create_seer_run( - organization=self.group.organization, seer_run_state_id=12345 - ) - - with ( - patch("sentry.seer.autofix.autofix_agent.features.has", return_value=False), - patch("sentry.seer.autofix.autofix_agent.is_free_cohort_org", return_value=False), - ): - trigger_autofix_agent( - group=self.group, - step=AutofixStep.CODE_CHANGES, - referrer=AutofixReferrer.AGENTIC_TRIAGE, - allow_free_cohort=True, - ) - - mock_check_quota.assert_called_once() - @patch("sentry.quotas.backend.check_seer_quota", return_value=False) @patch("sentry.seer.autofix.autofix_agent.broadcast_webhooks_for_organization.delay") @patch("sentry.seer.autofix.autofix_agent.trigger_autofix_feature") @@ -975,6 +939,35 @@ def test_counts_declined_when_no_quota( } ] + @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), + ) + def test_counts_declined_when_out_of_credits( + self, + mock_check_seer_credits: MagicMock, + mock_client_class: MagicMock, + mock_incr: MagicMock, + mock_check_quota: MagicMock, + mock_record_run: MagicMock, + mock_broadcast: MagicMock, + ) -> None: + with pytest.raises(SeerOutOfCreditsException): + trigger_autofix_agent( + self.group, + AutofixStep.ROOT_CAUSE, + AutofixReferrer.ISSUE_SUMMARY_POST_PROCESS_FIXABILITY, + ) + + assert _trigger_attempts(mock_incr) == [ + { + "referrer": "issue_summary.post_process_fixability", + "step": "root_cause", + "outcome": "declined", + } + ] + @patch("sentry.seer.autofix.autofix_agent.trigger_autofix_feature") def test_counts_error_when_seer_rejects_run( self, @@ -1674,6 +1667,24 @@ def test_raises_permission_denied_when_coding_disabled(self) -> None: referrer=AutofixReferrer.UNKNOWN, ) + @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), + ) + @patch("sentry.seer.agent.client.make_agent_update_request") + def test_raises_when_out_of_credits( + self, mock_post: MagicMock, mock_check_seer_credits: MagicMock + ) -> None: + with pytest.raises(SeerOutOfCreditsException): + trigger_push_changes( + group=self.group, + run_id=123, + referrer=AutofixReferrer.UNKNOWN, + ) + + mock_post.assert_not_called() + @patch("sentry.seer.agent.client.make_agent_update_request") def test_passes_pr_description_suffix_ready_state_and_author( self, mock_post: MagicMock