mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-23 20:23:00 +00:00
feat(hanzo-tools): a cloud refusal carries its body and headers, send returns headers
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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]}
|
||||
Reference in New Issue
Block a user