Skip to content
Closed
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
10 changes: 9 additions & 1 deletion src/tin_lite/keyword_plan_activities.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"
Expand Down
38 changes: 38 additions & 0 deletions tests/test_keyword_plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading