feat(hanzoai): a usage refusal names its spent window and reads the /v1 envelope

This commit is contained in:
2026-10-04 16:46:07 -07:00
parent d5daeda70e
commit 10a9562e1d
3 changed files with 33 additions and 11 deletions
+2 -2
View File
@@ -422,8 +422,8 @@ against api.hanzo.ai and prints pass or fail per rule. Release: tag `hanzo-kai-v
the `X-Hanzo-*` headers, case-insensitive; an absent header is `None`. `UsageLimitError`
(a `Fault`) with one subclass per AI-router refusal code — `PlanAllowanceUsedError`,
`PaidPlanRequiredError`, `FreePlanCapError`, `ModelCapError`, `UsageCapExceededError`,
`InsufficientBalanceError`, the JS and Go names. `refusal(reply)` reads only the router's
nested `{"error": {code, ...}}` body; the money gate's flat body stays `Denied`.
`InsufficientBalanceError`, the JS and Go names, plus `window` (the spent session or day). `refusal(reply)` reads only the router's
nested `{"error": {code, ...}}` body (or a controller's `{status: "error", code, msg}`); the money gate's flat body stays `Denied`.
`Client.send` and `Client.response_deserialize` raise it, so every generated operation run
through `hanzoai.Client` does; a bare generated `ApiClient` still raises `ApiException`.
+14 -7
View File
@@ -17,7 +17,7 @@ generated operation the client runs::
The refusal states shares and ways on, never an amount: no field here is a
price, a count or a cap. The body is the AI router's, ``{"error": {message,
type, code, class, model, fallback, resets_at, upgrade_url, actions}}``; the
type, code, class, model, fallback, limit, resets_at, upgrade_url, actions}}``; the
money gate's flat refusal is :class:`hanzoai.Denied`, and a body with neither
shape stays the fault it was. The class names are the ones the JavaScript and
Go SDKs use.
@@ -121,9 +121,10 @@ class UsageLimitError(Fault):
`status` is the HTTP status and `code` the refusal. `usage_class` is the
model class it concerns (the body's ``class``); `model` and `fallback` name
the model refused and the one that would answer instead; `resets_at` is
when the refusal lifts; `actions` are the ways on, in the order the server
gave them. It is a :class:`hanzoai.Fault`, so it also carries `request`
the model refused and the one that would answer instead; `window` names the
request window that is spent (session or day, never its size); `resets_at`
is when the refusal lifts; `actions` are the ways on, in the order the
server gave them. It is a :class:`hanzoai.Fault`, so it also carries `request`
and `retry_after`, and ``except Fault`` still catches it.
"""
@@ -140,6 +141,7 @@ class UsageLimitError(Fault):
usage_class: Optional[str] = None,
model: Optional[str] = None,
fallback: Optional[str] = None,
window: Optional[str] = None,
resets_at: Optional[datetime] = None,
upgrade_url: Optional[str] = None,
actions: Tuple[Action, ...] = (),
@@ -152,6 +154,7 @@ class UsageLimitError(Fault):
self.usage_class = usage_class
self.model = model
self.fallback = fallback
self.window = window
self.resets_at = resets_at
self.upgrade_url = upgrade_url
self.actions = actions
@@ -210,13 +213,16 @@ CODES: Dict[str, Type[UsageLimitError]] = {
def refusal(reply: Reply) -> Optional[UsageLimitError]:
"""The :class:`UsageLimitError` `reply` carries, or `None` when it carries none.
Only a non-2xx whose body is ``{"error": {"code": ...}}`` with one of the
six codes is one; every other answer is left to the rule that already reads
it.
Only a non-2xx carrying one of the six codes is one, in the router's body
``{"error": {"code": ...}}`` or a controller's ``/v1`` envelope
``{"status": "error", "code", "msg"}``; every other answer is left to the
rule that already reads it.
"""
if 200 <= reply.status <= 299 or not isinstance(reply.body, dict):
return None
e = reply.body.get("error")
if not isinstance(e, dict) and reply.body.get("status") == "error":
e = {"code": reply.body.get("code"), "message": reply.body.get("msg")}
if not isinstance(e, dict):
return None
cls = CODES.get(_text(e, "code") or "")
@@ -231,6 +237,7 @@ def refusal(reply: Reply) -> Optional[UsageLimitError]:
usage_class=_text(e, "class"),
model=_text(e, "model"),
fallback=_text(e, "fallback"),
window=_text(e, "limit"),
resets_at=instant(e.get("resets_at")),
upgrade_url=_text(e, "upgrade_url"),
actions=tuple(Action.read(a) for a in acts if isinstance(a, dict)) if isinstance(acts, list) else (),
+17 -2
View File
@@ -100,9 +100,10 @@ PAID_PLAN_REQUIRED = {
USAGE_CAP_EXCEEDED = {
"error": {
"message": "This session's requests are spent. It resets shortly.",
"message": "You've used session requests on your plan. They reset at 2026-10-04T23:30:00Z.",
"type": "rate_limit_error",
"code": "usage_cap_exceeded",
"limit": "session",
"resets_at": "2026-10-04T23:30:00Z",
}
}
@@ -171,9 +172,22 @@ def test_model_cap_names_the_model_its_fallback_and_the_switch():
assert e.actions[0] == Action(kind="switch", label="Try Enso", model="enso")
def test_a_spent_window_is_named_never_sized():
e = refusal(reply(429, USAGE_CAP_EXCEEDED))
assert (e.window, e.resets_at) == ("session", datetime(2026, 10, 4, 23, 30, tzinfo=timezone.utc))
def test_a_controller_s_v1_envelope_reads_the_same():
e = refusal(
reply(429, {"status": "error", "code": "free_plan_cap", "msg": "Free plan: today's Kai requests are used."})
)
assert type(e) is FreePlanCapError
assert (e.message, e.usage_class, e.actions) == ("Free plan: today's Kai requests are used.", None, ())
def test_a_field_the_refusal_did_not_carry_is_none_not_empty():
e = refusal(reply(402, INSUFFICIENT_BALANCE))
assert (e.usage_class, e.model, e.fallback, e.resets_at, e.upgrade_url) == (None, None, None, None, None)
assert (e.usage_class, e.model, e.fallback, e.window, e.resets_at, e.upgrade_url) == (None,) * 6
assert e.actions == ()
@@ -186,6 +200,7 @@ def test_a_field_the_refusal_did_not_carry_is_none_not_empty():
# The retryable sibling and the free lane are not usage-limit refusals.
(503, {"error": {"code": "balance_unavailable", "message": "try again"}}),
(429, {"error": {"code": "pool_busy", "message": "busy"}}),
(400, {"status": "error", "code": "bad_request", "msg": "no"}),
(500, "upstream exploded"),
(402, None),
# A success never is one, whatever it carries.