diff --git a/backend/apps/agents/manager/run/handle_run_error.py b/backend/apps/agents/manager/run/handle_run_error.py index 29d2b5cf..e3a22bde 100644 --- a/backend/apps/agents/manager/run/handle_run_error.py +++ b/backend/apps/agents/manager/run/handle_run_error.py @@ -15,6 +15,8 @@ from backend.apps.agents.core.error_classify import ( is_long_context_error, is_transient_capacity_error, is_free_trial_exhausted, + is_out_of_tokens, + extract_reset_hint, is_auth_error, is_unknown_model_error, parse_retry_after, @@ -144,6 +146,31 @@ async def handle_run_error(e: Exception, session: AgentSession, session_id: str, "session_id": session_id, "message": error_msg.model_dump(mode="json"), }) + elif is_out_of_tokens(e, extra_text=p_stderr_tail): + # The user's PROVIDER account is out of credits / over quota, distinct from + # OpenSwarm free-trial exhaustion above and from a 401 below ("credit balance + # too low", "insufficient_quota", "usage cap exceeded", OpenSwarm plan limit). + # Show a friendly card with the provider's reset hint when it gave one, instead + # of dropping to the raw-error blob in the else branch. + p_reset_hint = extract_reset_hint(f"{e!s}\n{p_stderr_tail}") + friendly_msg = ( + "Your model provider reports you're out of credits or over your usage " + "limit" + (f" (resets {p_reset_hint})" if p_reset_hint else "") + ". Add " + "credits with your provider, switch to a different model, or connect " + "another option in Settings → Models." + ) + error_msg = Message(role="system", content=friendly_msg, branch_id=session.active_branch_id) + session.messages.append(error_msg) + await ws_manager.send_to_session(session_id, "agent:out_of_credits", { + "session_id": session_id, + "message": friendly_msg, + "reset_hint": p_reset_hint, + "model": session.model, + }) + await ws_manager.send_to_session(session_id, "agent:message", { + "session_id": session_id, + "message": error_msg.model_dump(mode="json"), + }) elif is_auth_error(e, extra_text=p_stderr_tail): # Three sub-cases the user can hit, with distinct fixes: # 1. "No credentials for provider: claude", user picked a diff --git a/backend/tests/test_handle_run_error.py b/backend/tests/test_handle_run_error.py new file mode 100644 index 00000000..fac6a49b --- /dev/null +++ b/backend/tests/test_handle_run_error.py @@ -0,0 +1,43 @@ +"""Drive handle_run_error's out-of-credits branch. The is_out_of_tokens / extract_reset_hint +helpers were built but never wired in, so a provider credit/quota error fell through to the +raw-error blob; this pins the friendly card + agent:out_of_credits event (and the reset hint).""" + +import asyncio + +import backend.apps.agents.core.ws_manager as ws_mod +from backend.apps.agents.core.models import AgentSession +from backend.apps.agents.manager.run.handle_run_error import handle_run_error +from backend.apps.agents.manager.streaming.state import TurnState + + +def p_drive_error(monkeypatch, exc): + events = [] + + async def fake_send(session_id, event, data): + events.append((event, data)) + + monkeypatch.setattr(ws_mod.ws_manager, "send_to_session", fake_send, raising=True) + session = AgentSession(name="t", model="sonnet", dashboard_id="d") + asyncio.run(handle_run_error(exc, session, session.id, TurnState(), [])) + return session, events + + +def test_out_of_credits_shows_friendly_card_not_raw_error(monkeypatch): + session, events = p_drive_error( + monkeypatch, Exception("Your credit balance is too low to run this request") + ) + assert session.status == "error" + assert "agent:out_of_credits" in [e for e, _ in events] + sys_msgs = [m for m in session.messages if m.role == "system"] + assert sys_msgs, "expected a system card" + assert "out of credits or over your usage limit" in sys_msgs[-1].content + assert not sys_msgs[-1].content.startswith("Error:") # not the raw-error fallthrough + + +def test_out_of_credits_carries_the_provider_reset_hint(monkeypatch): + _, events = p_drive_error( + monkeypatch, Exception("insufficient_quota; resets at 7:42 AM") + ) + payload = next(d for e, d in events if e == "agent:out_of_credits") + assert payload["reset_hint"] == "at 7:42 AM" + assert "resets at 7:42 AM" in payload["message"]