mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-27 04:20:09 +00:00
508 lines
19 KiB
Python
508 lines
19 KiB
Python
"""Tests for hanzo-tools-llm: llm (Enso, the catalog, feedback) and kai_decide.
|
|
|
|
The tools run against a real HanzoCloud whose httpx transport is a recorder
|
|
answering as api.hanzo.ai does, so each test reads the exact request that went
|
|
out (method, path, bearer, Enso's headers, body) and what the tool made of the
|
|
answer. No socket is opened.
|
|
"""
|
|
|
|
import json
|
|
import asyncio
|
|
|
|
import httpx
|
|
import pytest
|
|
from hanzo_tools.llm import TOOLS, KaiDecideTool, UnifiedLLMTool
|
|
from hanzo_tools.core import HanzoCloud
|
|
|
|
COMPLETION = {
|
|
"id": "chatcmpl-ae7c311d-5c64-47da-886b-86ceb2fd185f",
|
|
"object": "chat.completion",
|
|
"model": "enso-free",
|
|
"choices": [
|
|
{"index": 0, "finish_reason": "stop", "message": {"role": "assistant", "content": "Hi there, friend."}}
|
|
],
|
|
"usage": {"prompt_tokens": 110, "completion_tokens": 6, "total_tokens": 116},
|
|
}
|
|
CATALOG = {
|
|
"object": "list",
|
|
"data": [
|
|
{
|
|
"id": "kai",
|
|
"family": "kai",
|
|
"class": "ours",
|
|
"outputs": ["decision"],
|
|
"pricing": {"input_per_million": 0.021, "output_per_million": 0},
|
|
},
|
|
{
|
|
"id": "enso-auto",
|
|
"family": "enso",
|
|
"class": "free",
|
|
"outputs": ["text"],
|
|
"context_window": 1000000,
|
|
"pricing": {"input_per_million": 0, "output_per_million": 0},
|
|
},
|
|
{
|
|
"id": "zen5",
|
|
"family": "zen",
|
|
"class": "ours",
|
|
"pricing": {"input_per_million": 0.3, "output_per_million": 1.2},
|
|
},
|
|
{
|
|
"id": "anthropic/claude-sonnet-4.5",
|
|
"name": "Anthropic: Claude Sonnet 4.5",
|
|
"owned_by": "anthropic",
|
|
"family": None,
|
|
"class": "premium",
|
|
"inputs": ["text", "image"],
|
|
"outputs": ["text"],
|
|
"supports_tools": True,
|
|
"supports_vision": True,
|
|
"supports_reasoning": True,
|
|
"pricing": {"input_per_million": 3, "output_per_million": 15},
|
|
},
|
|
{
|
|
"id": "openrouter/auto",
|
|
"name": "Auto Router",
|
|
"class": "premium",
|
|
"outputs": ["text"],
|
|
"pricing": {"input_per_million": 0, "output_per_million": 0, "variable": True},
|
|
},
|
|
],
|
|
}
|
|
#: A limits answer, plus three figures it must never hand on (spent_usd, used, cap).
|
|
LIMITS = {
|
|
"plan": "dev",
|
|
"state": "near",
|
|
"period_start": "2026-10-01T00:00:00Z",
|
|
"period_end": "2026-11-01T00:00:00Z",
|
|
"spent_usd": 731.25,
|
|
"classes": {
|
|
"premium": {
|
|
"percent": 85,
|
|
"state": "near",
|
|
"paying": "plan",
|
|
"resets_at": "2026-11-01T00:00:00Z",
|
|
"used": 987654,
|
|
"window": {"percent": 40, "state": "ok", "resets_at": "2026-10-05T03:00:00Z", "cap": 424242},
|
|
}
|
|
},
|
|
"session": {"percent": 10, "state": "ok", "resets_at": "2026-10-05T01:00:00Z"},
|
|
"day": {"percent": 35, "state": "ok", "resets_at": "2026-10-05T00:00:00Z"},
|
|
"paused": [{"model": "anthropic/claude-opus-4.1", "fallback": "enso", "resets_at": "2026-10-05T05:00:00Z"}],
|
|
"actions": [
|
|
{"kind": "upgrade", "label": "Upgrade to Max", "url": "https://hanzo.ai/pay/cart?plan=max-5x", "plan": "max-5x"}
|
|
],
|
|
"upgrade": "max-5x",
|
|
"credits_after_allowance": False,
|
|
}
|
|
#: Measured: POST /v1/decisions, model kai, org on the free plan.
|
|
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",
|
|
"upgrade_url": "https://hanzo.ai/pay/cart?plan=dev",
|
|
"actions": [
|
|
{
|
|
"kind": "upgrade",
|
|
"label": "Upgrade your plan",
|
|
"url": "https://hanzo.ai/pay/cart?plan=dev",
|
|
"plan": "dev",
|
|
},
|
|
{"kind": "topup", "label": "Add prepaid credit", "url": "https://hanzo.ai/pay"},
|
|
],
|
|
}
|
|
}
|
|
MODEL_CAP = {
|
|
"error": {
|
|
"message": "This model has used its share of your plan for now. https://hanzo.ai/pay",
|
|
"type": "billing_error",
|
|
"code": "model_cap",
|
|
"class": "premium",
|
|
"model": "anthropic/claude-opus-4.1",
|
|
"fallback": "enso",
|
|
"resets_at": "2026-10-05T05:00:00Z",
|
|
"upgrade_url": "https://hanzo.ai/pay/cart?plan=max-5x",
|
|
"actions": [
|
|
{"kind": "switch", "label": "Try Enso", "model": "enso"},
|
|
{"kind": "topup", "label": "Add prepaid credit", "url": "https://hanzo.ai/pay"},
|
|
],
|
|
}
|
|
}
|
|
DECISION = {
|
|
"id": "dec_ff9388f6b8b85328960ba301635ac108",
|
|
"model": "kai",
|
|
"provider": "Hanzo",
|
|
"answers": {
|
|
"team": {
|
|
"type": "choice",
|
|
"choice": "payments",
|
|
"confidence": 0.9983,
|
|
"probabilities": {"account": 0.0006, "payments": 0.9989},
|
|
"answer_confidence": 0.9989,
|
|
}
|
|
},
|
|
"usage": {"input_tokens": 30, "output_tokens": 0},
|
|
}
|
|
|
|
|
|
class Gateway:
|
|
"""Records each request and answers it the way api.hanzo.ai does."""
|
|
|
|
def __init__(self):
|
|
self.seen: list[httpx.Request] = []
|
|
self.answer = None # (status, body) or (status, body, headers) to force one reply
|
|
|
|
def __call__(self, request: httpx.Request) -> httpx.Response:
|
|
self.seen.append(request)
|
|
if self.answer:
|
|
status, body, *headers = self.answer
|
|
return httpx.Response(status, json=body, headers=headers[0] if headers else None)
|
|
path = request.url.path
|
|
if path == "/v1/models":
|
|
return httpx.Response(200, json=CATALOG)
|
|
if path == "/v1/ai/limits":
|
|
return httpx.Response(200, json=LIMITS)
|
|
if path == "/v1/decisions":
|
|
return httpx.Response(200, json=DECISION)
|
|
if path == "/v1/ai/feedback":
|
|
sent = json.loads(request.content)
|
|
return httpx.Response(
|
|
200,
|
|
json={
|
|
"status": "ok",
|
|
"msg": "",
|
|
"data": {"request_id": sent["request_id"].removeprefix("chatcmpl-"), "reward": 1, "recorded": True},
|
|
},
|
|
)
|
|
return httpx.Response(200, json=COMPLETION, headers={"X-Routed-Model": "enso-free"})
|
|
|
|
@property
|
|
def last(self) -> httpx.Request:
|
|
return self.seen[-1]
|
|
|
|
|
|
def wired(tool_class):
|
|
gw = Gateway()
|
|
cloud = HanzoCloud(base_url="https://api.hanzo.ai", key="sk-test")
|
|
cloud._client = httpx.AsyncClient(base_url="https://api.hanzo.ai", transport=httpx.MockTransport(gw))
|
|
tool = tool_class()
|
|
tool._cloud = cloud
|
|
return tool, gw
|
|
|
|
|
|
def run(tool, **kw):
|
|
return asyncio.run(tool.call(None, **kw))
|
|
|
|
|
|
def body(req: httpx.Request):
|
|
return json.loads(req.content) if req.content else None
|
|
|
|
|
|
def test_the_package_carries_llm_and_kai_decide():
|
|
assert [t.name for t in TOOLS] == ["llm", "kai_decide"]
|
|
assert UnifiedLLMTool.DEFAULT_ACTION == "query"
|
|
assert KaiDecideTool.DEFAULT_ACTION == "decide"
|
|
|
|
|
|
def test_query_defaults_to_enso_auto_and_sends_no_unasked_bound():
|
|
tool, gw = wired(UnifiedLLMTool)
|
|
env = run(tool, prompt="Say hi.")
|
|
assert env["ok"], env
|
|
assert (gw.last.method, gw.last.url.path) == ("POST", "/v1/chat/completions")
|
|
assert gw.last.headers["authorization"] == "Bearer sk-test"
|
|
assert "x-max-cost" not in gw.last.headers and "x-max-latency-ms" not in gw.last.headers
|
|
assert body(gw.last) == {"model": "enso-auto", "messages": [{"role": "user", "content": "Say hi."}]}
|
|
|
|
|
|
def test_query_carries_auto_and_the_bounds_and_returns_the_model_that_served():
|
|
tool, gw = wired(UnifiedLLMTool)
|
|
env = run(
|
|
tool,
|
|
action="query",
|
|
model="auto",
|
|
system="Be brief.",
|
|
prompt="Say hi.",
|
|
max_tokens=20,
|
|
max_cost=0.01,
|
|
max_latency_ms=800.4,
|
|
)
|
|
assert gw.last.headers["x-max-cost"] == "0.01"
|
|
assert gw.last.headers["x-max-latency-ms"] == "800"
|
|
assert body(gw.last) == {
|
|
"model": "auto",
|
|
"messages": [{"role": "system", "content": "Be brief."}, {"role": "user", "content": "Say hi."}],
|
|
"max_tokens": 20,
|
|
}
|
|
assert env["data"] == {
|
|
"id": COMPLETION["id"],
|
|
"model": "enso-free",
|
|
"content": "Hi there, friend.",
|
|
"finish_reason": "stop",
|
|
"usage": COMPLETION["usage"],
|
|
"served": None,
|
|
"paid_by": None,
|
|
"usage_state": None,
|
|
"usage_class": None,
|
|
"fallback": None,
|
|
"fallback_reason": None,
|
|
}
|
|
|
|
|
|
def test_query_reads_who_answered_and_who_paid_from_the_headers():
|
|
tool, gw = wired(UnifiedLLMTool)
|
|
gw.answer = (
|
|
200,
|
|
{**COMPLETION, "model": "anthropic/claude-sonnet-4.5"},
|
|
{
|
|
"x-hanzo-usage": "ok",
|
|
"x-hanzo-usage-class": "premium",
|
|
"x-hanzo-paid-by": "credits",
|
|
"x-hanzo-served": "anthropic/claude-sonnet-4.5",
|
|
},
|
|
)
|
|
data = run(tool, prompt="Say ok", model="anthropic/claude-sonnet-4.5")["data"]
|
|
assert (data["model"], data["served"], data["paid_by"], data["usage_state"], data["usage_class"]) == (
|
|
"anthropic/claude-sonnet-4.5",
|
|
"anthropic/claude-sonnet-4.5",
|
|
"credits",
|
|
"ok",
|
|
"premium",
|
|
)
|
|
assert (data["fallback"], data["fallback_reason"]) == (None, None)
|
|
|
|
|
|
def test_query_fallback_opts_in_and_the_answer_names_who_answered_instead():
|
|
tool, gw = wired(UnifiedLLMTool)
|
|
gw.answer = (
|
|
200,
|
|
{**COMPLETION, "model": "enso"},
|
|
{
|
|
"x-hanzo-usage": "limited",
|
|
"x-hanzo-fallback": "enso",
|
|
"x-hanzo-usage-reason": "model_cap",
|
|
"x-hanzo-served": "enso",
|
|
},
|
|
)
|
|
data = run(tool, prompt="hi", model="anthropic/claude-opus-4.1", fallback=True)["data"]
|
|
assert gw.last.headers["x-hanzo-fallback"] == "allow"
|
|
assert (data["fallback"], data["fallback_reason"], data["usage_state"]) == ("enso", "model_cap", "limited")
|
|
run(tool, prompt="hi")
|
|
assert "x-hanzo-fallback" not in gw.last.headers
|
|
|
|
|
|
def test_query_takes_a_conversation_and_refuses_a_bad_bound_before_sending():
|
|
tool, gw = wired(UnifiedLLMTool)
|
|
msgs = [{"role": "user", "content": "a"}, {"role": "assistant", "content": "b"}, {"role": "user", "content": "c"}]
|
|
run(tool, messages=msgs, model="enso-pro")
|
|
assert body(gw.last) == {"model": "enso-pro", "messages": msgs}
|
|
env = run(tool, prompt="hi", max_cost=-1)
|
|
assert not env["ok"] and "max_cost must be a positive number" in env["error"]["message"]
|
|
assert len(gw.seen) == 1
|
|
|
|
|
|
def test_a_refusal_carries_the_status_and_the_servers_sentence():
|
|
tool, gw = wired(UnifiedLLMTool)
|
|
gw.answer = (402, {"error": {"message": "Pick a plan", "type": "billing_error"}})
|
|
env = run(tool, prompt="hi")
|
|
assert not env["ok"]
|
|
assert env["error"]["code"] == "UPSTREAM"
|
|
assert "402" in env["error"]["message"] and "Pick a plan" in env["error"]["message"]
|
|
|
|
|
|
def test_models_lists_the_catalogs_own_prices_by_family():
|
|
tool, gw = wired(UnifiedLLMTool)
|
|
env = run(tool, action="models", family="kai")
|
|
assert (gw.last.method, gw.last.url.path) == ("GET", "/v1/models")
|
|
assert env["data"] == {
|
|
"count": 1,
|
|
"models": [
|
|
{
|
|
"id": "kai",
|
|
"family": "kai",
|
|
"class": "ours",
|
|
"outputs": ["decision"],
|
|
"context_window": None,
|
|
"supports": [],
|
|
"input_per_million": 0.021,
|
|
"output_per_million": 0,
|
|
"variable": False,
|
|
}
|
|
],
|
|
}
|
|
assert run(tool, action="models")["data"]["count"] == 5
|
|
|
|
|
|
def test_models_searches_by_class_capability_and_words_and_shows_the_price():
|
|
tool, gw = wired(UnifiedLLMTool)
|
|
ids = lambda env: [m["id"] for m in env["data"]["models"]] # noqa: E731
|
|
assert ids(run(tool, action="models", **{"class": "ours"})) == ["kai", "zen5"]
|
|
assert ids(run(tool, action="models", **{"class": "premium"}, capability="vision")) == [
|
|
"anthropic/claude-sonnet-4.5"
|
|
]
|
|
assert ids(run(tool, action="models", capability="decision")) == ["kai"]
|
|
sonnet = run(tool, action="models", search="claude sonnet")["data"]["models"]
|
|
assert [(m["id"], m["input_per_million"], m["output_per_million"], m["supports"]) for m in sonnet] == [
|
|
("anthropic/claude-sonnet-4.5", 3, 15, ["tools", "vision", "reasoning"])
|
|
]
|
|
auto = run(tool, action="models", search="router")["data"]["models"]
|
|
assert [(m["id"], m["variable"]) for m in auto] == [("openrouter/auto", True)]
|
|
n = len(gw.seen)
|
|
env = run(tool, action="models", **{"class": "gold"})
|
|
assert not env["ok"] and "class must be one of premium, ours, free" in env["error"]["message"]
|
|
assert len(gw.seen) == n
|
|
|
|
|
|
def test_limits_reads_shares_states_resets_and_actions_and_never_a_figure():
|
|
tool, gw = wired(UnifiedLLMTool)
|
|
env = run(tool, action="limits")
|
|
assert (gw.last.method, gw.last.url.path) == ("GET", "/v1/ai/limits")
|
|
d = env["data"]
|
|
assert (d["plan"], d["state"], d["upgrade"], d["credits_after_allowance"]) == ("dev", "near", "max-5x", False)
|
|
assert d["classes"] == {
|
|
"premium": {
|
|
"percent": 85,
|
|
"state": "near",
|
|
"resets_at": "2026-11-01T00:00:00Z",
|
|
"paying": "plan",
|
|
"window": {"percent": 40, "state": "ok", "resets_at": "2026-10-05T03:00:00Z"},
|
|
}
|
|
}
|
|
assert d["day"] == {"percent": 35, "state": "ok", "resets_at": "2026-10-05T00:00:00Z"}
|
|
assert d["paused"] == [
|
|
{"model": "anthropic/claude-opus-4.1", "fallback": "enso", "resets_at": "2026-10-05T05:00:00Z"}
|
|
]
|
|
assert d["actions"][0]["url"] == "https://hanzo.ai/pay/cart?plan=max-5x"
|
|
wire = json.dumps(env)
|
|
for figure in ("731.25", "987654", "424242", "spent_usd", '"used"', '"cap"'):
|
|
assert figure not in wire
|
|
|
|
|
|
def test_limits_on_a_free_plan_with_nothing_used():
|
|
tool, gw = wired(UnifiedLLMTool)
|
|
gw.answer = (
|
|
200,
|
|
{
|
|
"plan": "free",
|
|
"state": "ok",
|
|
"classes": {},
|
|
"actions": [{"kind": "topup", "label": "Add prepaid credit", "url": "https://hanzo.ai/pay"}],
|
|
"upgrade": "dev",
|
|
"credits_after_allowance": False,
|
|
},
|
|
)
|
|
d = run(tool, action="limits")["data"]
|
|
assert (d["plan"], d["state"], d["classes"], d["session"], d["limited"], d["paused"]) == (
|
|
"free",
|
|
"ok",
|
|
{},
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
|
|
|
|
def test_a_plan_refusal_is_an_error_named_by_its_code_with_the_ways_on():
|
|
tool, gw = wired(UnifiedLLMTool)
|
|
gw.answer = (402, MODEL_CAP, {"x-hanzo-usage": "limited", "x-hanzo-usage-class": "premium"})
|
|
env = run(tool, prompt="hi", model="anthropic/claude-opus-4.1")
|
|
assert not env["ok"]
|
|
e = env["error"]
|
|
assert (e["code"], e["status"], e["class"], e["model"], e["fallback"], e["resets_at"]) == (
|
|
"model_cap",
|
|
402,
|
|
"premium",
|
|
"anthropic/claude-opus-4.1",
|
|
"enso",
|
|
"2026-10-05T05:00:00Z",
|
|
)
|
|
assert e["message"] == MODEL_CAP["error"]["message"]
|
|
# The upgrade the body named only as upgrade_url leads the actions.
|
|
assert e["actions"] == [
|
|
{"kind": "upgrade", "url": "https://hanzo.ai/pay/cart?plan=max-5x"},
|
|
{"kind": "switch", "label": "Try Enso", "model": "enso"},
|
|
{"kind": "topup", "label": "Add prepaid credit", "url": "https://hanzo.ai/pay"},
|
|
]
|
|
assert "window" not in e
|
|
|
|
|
|
def test_feedback_posts_the_signal_and_returns_what_was_recorded():
|
|
tool, gw = wired(UnifiedLLMTool)
|
|
env = run(tool, action="feedback", request_id=COMPLETION["id"], signal="rating", rating=3)
|
|
assert (gw.last.method, gw.last.url.path) == ("POST", "/v1/ai/feedback")
|
|
assert body(gw.last) == {"request_id": COMPLETION["id"], "signal": "rating", "rating": 3}
|
|
assert env["data"] == {"request_id": "ae7c311d-5c64-47da-886b-86ceb2fd185f", "reward": 1, "recorded": True}
|
|
|
|
|
|
def test_feedback_refuses_what_the_server_would_and_reads_an_error_envelope():
|
|
tool, gw = wired(UnifiedLLMTool)
|
|
assert (
|
|
"signal must be one of"
|
|
in run(tool, action="feedback", request_id="chatcmpl-1", signal="bogus")["error"]["message"]
|
|
)
|
|
assert (
|
|
"rating must be 1, 2 or 3"
|
|
in run(tool, action="feedback", request_id="chatcmpl-1", signal="rating", rating=4)["error"]["message"]
|
|
)
|
|
assert gw.seen == []
|
|
gw.answer = (200, {"status": "error", "msg": "request not found", "data": None})
|
|
env = run(tool, action="feedback", request_id="chatcmpl-1", signal="down")
|
|
assert not env["ok"] and env["error"]["message"] == "request not found"
|
|
|
|
|
|
def test_kai_decide_posts_the_decision_and_returns_it():
|
|
tool, gw = wired(KaiDecideTool)
|
|
questions = {
|
|
"team": {
|
|
"type": "choice",
|
|
"instructions": "Which team?",
|
|
"criteria": {"account": "logins", "payments": "charges and refunds"},
|
|
}
|
|
}
|
|
env = run(tool, state={"message": "charged twice"}, questions=questions)
|
|
assert env["ok"], env
|
|
assert env["data"] == DECISION
|
|
assert (gw.last.method, gw.last.url.path) == ("POST", "/v1/decisions")
|
|
assert body(gw.last) == {"model": "kai", "state": {"message": "charged twice"}, "questions": questions}
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"question,said",
|
|
[
|
|
({"type": "vote"}, "type must be one of"),
|
|
({"type": "choice", "criteria": ["a", "a"]}, "at least 2 labels, not 1"),
|
|
({"type": "score", "criteria": []}, "at least 1 level"),
|
|
({"type": "score", "criteria": ["a", None]}, "score level 1 is null"),
|
|
({"type": "noul", "criteria": {"maybe": "x"}}, "not maybe"),
|
|
],
|
|
)
|
|
def test_kai_decide_names_each_shape_the_contract_refuses(question, said):
|
|
tool, gw = wired(KaiDecideTool)
|
|
env = run(tool, state="s", questions={"q": question})
|
|
assert not env["ok"] and said in env["error"]["message"]
|
|
assert gw.seen == []
|
|
|
|
|
|
def test_kai_decide_carries_the_plan_cap_refusal():
|
|
tool, gw = wired(KaiDecideTool)
|
|
gw.answer = (429, FREE_PLAN_CAP, {"retry-after": "3032", "x-hanzo-usage": "limited", "x-hanzo-usage-class": "ours"})
|
|
env = run(tool, state="s", questions={"q": {"type": "noul"}}, model="kai")
|
|
assert not env["ok"]
|
|
e = env["error"]
|
|
assert (e["code"], e["status"], e["class"], e["resets_at"]) == (
|
|
"free_plan_cap",
|
|
429,
|
|
"ours",
|
|
"2026-10-05T00:00:00Z",
|
|
)
|
|
assert e["message"].startswith("Free plan: today's Kai requests are used.")
|
|
assert [a["kind"] for a in e["actions"]] == ["upgrade", "topup"]
|
|
|
|
|
|
def test_kai_decide_asks_the_model_named():
|
|
tool, gw = wired(KaiDecideTool)
|
|
run(tool, state="s", questions={"q": {"type": "noul"}}, model="typesafe/jev-1.13")
|
|
assert body(gw.last)["model"] == "typesafe/jev-1.13"
|