diff --git a/src/tin_lite/keyword_plan_activities.py b/src/tin_lite/keyword_plan_activities.py index 2dc41e78..a43df516 100644 --- a/src/tin_lite/keyword_plan_activities.py +++ b/src/tin_lite/keyword_plan_activities.py @@ -312,7 +312,12 @@ async def _paid( # Raw SDK/provider errors, headers and credentials never enter evidence; # a model call keeps only a fixed label for why it failed. result = ( - {"status": "unavailable", "reason": "spending_limit"} + { + "status": "unavailable", + "reason": "spending_limit" + if exc.code in {"spending_limit", "run_limit", "project_limit"} + else f"billing_refused:{exc.code}", + } if isinstance(exc, BillingError) else { "status": "unknown", @@ -1420,6 +1425,9 @@ def model_failure_cause(failure: dict) -> str: """Say in plain words why a model step stopped, from its stage and fixed reason label.""" stage = STAGE_NAMES.get(failure.get("stage"), "keyword") reason = failure.get("reason") or "" + if reason.startswith("billing_refused:"): + code = reason.removeprefix("billing_refused:") + return f"a billing check refused the {stage} model call ({code})" if reason == "provider_timeout": seconds = MODEL_TIMEOUT_SECONDS.get(failure.get("stage")) limit = f"within {seconds // 60} minutes" if seconds else "in time" diff --git a/tests/test_keyword_plan.py b/tests/test_keyword_plan.py index 910dec6d..891f9875 100644 --- a/tests/test_keyword_plan.py +++ b/tests/test_keyword_plan.py @@ -555,6 +555,44 @@ async def test_unknown_attempt_never_repurchased_and_fingerprint_is_enforced(): assert result["status"] == "unknown" and call.await_count == 0 +@pytest.mark.asyncio +@pytest.mark.parametrize( + "code", + [ + "spending_limit", + "run_limit", + "project_limit", + "unpriced_model", + "unmetered_operation", + "operation_already_attempted", + "usage_pending", + "insufficient_funds", + "spending_stopped", + ], +) +async def test_keyword_billing_refusal_keeps_its_cause_and_does_not_buy_again(code): + from tin_lite.billing_contracts import BillingError + + activities, db, _, _, model = await fixture() + run_id = str(db.run.id) + model.generate.side_effect = BillingError(code, "Private diagnostic must not escape") + for _ in range(2): + with pytest.raises(ApplicationError): + await activities._model(run_id, "review", {}) + is_limit = code in {"spending_limit", "run_limit", "project_limit"} + reason = "spending_limit" if is_limit else f"billing_refused:{code}" + receipt = db.effects[activities.key(run_id, "review")].result + assert receipt["status"] == "unavailable" and receipt["reason"] == reason + assert model.generate.await_count == 1 + assert "Private diagnostic" not in json.dumps(receipt) + await activities.keyword_failure(run_id) + message = db.project_failure.await_args.kwargs["error_message"] + assert ("spending limit" in message) is is_limit + if not is_limit: + assert code in message and "could not be confirmed" not in message + assert "Private diagnostic" not in message + + @pytest.mark.asyncio async def test_budget_reserves_review_before_research_and_recovers_prepare_gap(): activities, db, _, provider, _ = await fixture(budget=5)