feat(hanzo-tools): a cloud refusal carries its body and headers, send returns headers

This commit is contained in:
2026-10-04 16:40:55 -07:00
parent 4240afb736
commit d5daeda70e
5 changed files with 112 additions and 7 deletions
@@ -24,6 +24,7 @@ class FakeResponse:
self.status_code = status
self.payload = payload
self.text = json.dumps(payload)
self.headers = {}
def json(self):
return self.payload
@@ -202,6 +202,10 @@ class FakeSSEResponse:
self.lines = lines
self.status_code = status
self.text = body
self.headers = {}
def json(self):
return json.loads(self.text)
async def aiter_lines(self):
for line in self.lines:
+41 -6
View File
@@ -17,7 +17,7 @@ import json
import subprocess
from typing import Any, ClassVar
from pathlib import Path
from collections.abc import AsyncIterator
from collections.abc import Mapping, AsyncIterator
DEFAULT_BASE = "https://api.hanzo.ai"
@@ -100,11 +100,22 @@ class CloudError(Exception):
``status`` is the HTTP status when the server answered, else None (a
transport/DNS failure). Tools inspect it to decide fall-back vs surface.
``body`` is the answer's decoded JSON (None when it was not JSON or there
was no answer) and ``headers`` its headers, so a refusal can be read whole
(``hanzoai.usage.refusal``) rather than out of the trimmed message.
"""
def __init__(self, message: str, status: int | None = None):
def __init__(
self,
message: str,
status: int | None = None,
body: Any = None,
headers: Mapping[str, str] | None = None,
):
super().__init__(message)
self.status = status
self.body = body
self.headers = headers if headers is not None else {}
class HanzoCloud:
@@ -156,9 +167,15 @@ class HanzoCloud:
)
return self._client
async def _request(
async def send(
self, method: str, path: str, headers: dict[str, str] | None = None, **kw: Any
) -> Any:
) -> tuple[Any, Mapping[str, str]]:
"""One request: the parsed JSON body and the answer's headers.
For a route whose headers say something (the ``X-Hanzo-*`` usage
headers on a priced call). ``kw`` passes to httpx (``params``,
``json``). Raises CloudError on a transport failure or a non-2xx.
"""
client = await self._get_client()
try:
resp = await client.request(
@@ -168,14 +185,26 @@ class HanzoCloud:
raise CloudError(f"{method} {path} failed: {e}") from e
if resp.status_code >= 400:
try:
body = resp.json()
except ValueError:
body = None
raise CloudError(
f"{method} {path} → {resp.status_code}: {_short_body(resp)}",
status=resp.status_code,
body=body,
headers=resp.headers,
)
try:
return resp.json()
return resp.json(), resp.headers
except ValueError:
return {"text": resp.text}
return {"text": resp.text}, resp.headers
async def _request(
self, method: str, path: str, headers: dict[str, str] | None = None, **kw: Any
) -> Any:
body, _ = await self.send(method, path, headers=headers, **kw)
return body
async def get(self, path: str, params: dict[str, Any] | None = None) -> Any:
"""GET path, returning parsed JSON. Raises CloudError on failure."""
@@ -227,9 +256,15 @@ class HanzoCloud:
) as resp:
if resp.status_code >= 400:
await resp.aread()
try:
body = resp.json()
except ValueError:
body = None
raise CloudError(
f"POST {path} → {resp.status_code}: {_short_body(resp)}",
status=resp.status_code,
body=body,
headers=resp.headers,
)
async for line in resp.aiter_lines():
event = _sse_event(line)
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "hanzo-tools"
version = "0.3.8"
version = "0.3.9"
description = "Hanzo AI tools - core infrastructure and tool bundles"
readme = "README.md"
requires-python = ">=3.12"
+65
View File
@@ -0,0 +1,65 @@
"""Tests for hanzo_tools.core.cloud — what a refused call and an answered one hand back.
HanzoCloud runs against an httpx MockTransport answering as api.hanzo.ai does,
so no socket is opened.
"""
import asyncio
import httpx
import pytest
from hanzo_tools.core import CloudError, HanzoCloud
FREE_PLAN_CAP = {
"error": {
"message": "Free plan: today's Kai requests are used. Upgrade for more: https://hanzo.ai/pay",
"type": "rate_limit_error",
"code": "free_plan_cap",
"class": "ours",
"resets_at": "2026-10-05T00:00:00Z",
}
}
def cloud(handler):
c = HanzoCloud(base_url="https://api.hanzo.ai", key="sk-test")
c._client = httpx.AsyncClient(base_url="https://api.hanzo.ai", transport=httpx.MockTransport(handler))
return c
def test_send_hands_back_the_body_and_the_headers():
c = cloud(lambda r: httpx.Response(200, json={"ok": 1}, headers={"X-Hanzo-Paid-By": "credits"}))
body, headers = asyncio.run(c.send("POST", "/v1/chat/completions", json={"model": "m"}))
assert body == {"ok": 1}
assert headers["x-hanzo-paid-by"] == "credits"
def test_a_refusal_carries_its_whole_body_and_headers():
c = cloud(lambda r: httpx.Response(429, json=FREE_PLAN_CAP, headers={"X-Hanzo-Usage": "limited"}))
with pytest.raises(CloudError) as raised:
asyncio.run(c.post("/v1/decisions", {"model": "kai"}))
e = raised.value
assert e.status == 429
assert e.body == FREE_PLAN_CAP
assert e.headers["x-hanzo-usage"] == "limited"
assert "429" in str(e) and "free_plan_cap" in str(e)
def test_a_body_that_is_not_json_is_none_and_a_transport_failure_has_no_answer():
c = cloud(lambda r: httpx.Response(502, text="<html>bad gateway</html>"))
with pytest.raises(CloudError) as raised:
asyncio.run(c.get("/v1/models"))
assert (raised.value.status, raised.value.body) == (502, None)
def down(request):
raise httpx.ConnectError("no route", request=request)
with pytest.raises(CloudError) as raised:
asyncio.run(cloud(down).get("/v1/models"))
assert (raised.value.status, raised.value.body, dict(raised.value.headers)) == (None, None, {})
def test_get_and_post_still_answer_the_body_alone():
c = cloud(lambda r: httpx.Response(200, json={"data": [1, 2]}))
assert asyncio.run(c.get("/v1/models")) == {"data": [1, 2]}
assert asyncio.run(c.post("/v1/x", {})) == {"data": [1, 2]}