mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-23 20:23:00 +00:00
feat(hanzoai): a usage refusal names its spent window and reads the /v1 envelope
This commit is contained in:
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user