mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-07-16 12:40:18 +00:00
93f447c7af
'All 0 credential(s) exhausted for <provider>' did not match _is_quota_error_text so it fell through to a generic 'Error' with no hint (the exception path's else branch discarded the hint entirely). Adds a distinct credential_pool_empty classification (ordered before the quota check) + an explicit exception-path branch + an actionable pool-specific hint, so the user sees why the turn failed and what to do. Does not over-match (doesn't swallow real quota errors) and doesn't block partial-harvest; downstream tolerates the new type. NOTE: this is the CLASSIFICATION half of #3929 only — the data-loss half (reasoning/partial-work restore on reload-reconcile) is a scoped follow-up that needs the reporter's server logs. Self-built (nesquena-hermes); nesquena independent review APPROVED.
53 lines
2.5 KiB
Python
53 lines
2.5 KiB
Python
"""Regression test for #3929 — credential-pool exhaustion classification.
|
|
|
|
Opus-advise on #3929 flagged that the error text a zero-credential pool produces,
|
|
`"All 0 credential(s) exhausted for opencode_go"`, does NOT match
|
|
_is_quota_error_text ('credential(s) exhausted' != 'credits exhausted'), so it
|
|
fell through to the generic 'Error' label with an empty hint — and in the
|
|
exception path the else-branch discarded the classification hint entirely.
|
|
|
|
This does not by itself fix the partial-work loss (that needs the reload-reconcile
|
|
reasoning-restore work + reporter logs for the incident autopsy — tracked
|
|
separately on the issue), but it gives the user an accurate, actionable message
|
|
for the exact error they hit instead of a bare 'Error'.
|
|
"""
|
|
from api import streaming
|
|
|
|
|
|
CREDENTIAL_POOL_ERRORS = [
|
|
"All 0 credential(s) exhausted for opencode_go",
|
|
"All 3 credential(s) exhausted for openrouter",
|
|
"credentials exhausted",
|
|
]
|
|
|
|
|
|
def test_credential_pool_exhaustion_classified_distinctly():
|
|
"""The 'N credential(s) exhausted' pool shape gets its own classification —
|
|
not generic 'error', not mislabeled as account quota."""
|
|
for err in CREDENTIAL_POOL_ERRORS:
|
|
classified = streaming._classify_provider_error(err, Exception(err))
|
|
assert classified["type"] == "credential_pool_empty", err
|
|
# Must carry an actionable, pool-specific hint (not empty, not top-up-account).
|
|
assert classified["hint"], f"empty hint for {err!r}"
|
|
assert "credential pool" in classified["hint"].lower(), err
|
|
assert classified["label"] == "No usable credentials", err
|
|
|
|
|
|
def test_credential_pool_is_not_misclassified_as_account_quota():
|
|
"""A zero-credential POOL is a config problem, not account credit exhaustion:
|
|
it must NOT get the 'Out of credits / top up your account' quota hint."""
|
|
classified = streaming._classify_provider_error(
|
|
"All 0 credential(s) exhausted for opencode_go",
|
|
Exception("All 0 credential(s) exhausted for opencode_go"),
|
|
)
|
|
assert classified["type"] != "quota_exhausted"
|
|
assert "top up" not in classified["hint"].lower()
|
|
|
|
|
|
def test_real_account_quota_still_classifies_as_quota():
|
|
"""Guard against over-matching: genuine account-credit exhaustion phrases
|
|
must still classify as quota_exhausted, not credential_pool_empty."""
|
|
for err in ("insufficient credits", "credits exhausted", "you have exceeded your current quota"):
|
|
classified = streaming._classify_provider_error(err, Exception(err))
|
|
assert classified["type"] == "quota_exhausted", err
|