mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-23 20:23:00 +00:00
356 lines
14 KiB
Python
356 lines
14 KiB
Python
import json
|
|
import math
|
|
import logging
|
|
from types import MappingProxyType
|
|
from collections.abc import Callable
|
|
|
|
import httpx
|
|
import pytest
|
|
from wire import KEY, BODY, Wire, Client, body, reply
|
|
from pydantic import BaseModel, ValidationError
|
|
from hanzo_kai import (
|
|
Noul,
|
|
Score,
|
|
Choice,
|
|
Decision,
|
|
KaiError,
|
|
NoulAnswer,
|
|
ScoreAnswer,
|
|
ChoiceAnswer,
|
|
BadRequestError,
|
|
APIResponseValidationError,
|
|
__version__,
|
|
)
|
|
|
|
Build = Callable[..., Client]
|
|
|
|
STATE = {"ticket": "I was charged twice for my March invoice. Please refund the duplicate."}
|
|
QUESTIONS = {
|
|
"team": Choice(instructions="Which team should handle this?", criteria={"billing": "charges", "tech": None}),
|
|
"billing": Noul(instructions="Is this about billing?"),
|
|
"urgency": Score(instructions="How urgent is this?", criteria=["can wait", "this week", "today"]),
|
|
}
|
|
|
|
|
|
def test_request_is_the_wire_shape(client: Build) -> None:
|
|
wire = Wire()
|
|
client(wire).decide(
|
|
STATE,
|
|
{
|
|
**QUESTIONS,
|
|
"kind": Choice(instructions={"task": "Pick one"}, criteria=["refund", "question"]),
|
|
"sure": Noul(instructions="Is it sure?", criteria={"true": "certain", "false": None}),
|
|
"raw": {"type": "noul", "instructions": "Raw?", "criteria": {"true": "yes"}},
|
|
},
|
|
)
|
|
request = wire.requests[0]
|
|
assert request.method == "POST"
|
|
assert str(request.url) == "https://api.hanzo.ai/v1/decisions"
|
|
sent = wire.json()
|
|
assert list(sent) == ["model", "state", "questions"]
|
|
assert sent == {
|
|
"model": "kai",
|
|
"state": STATE,
|
|
"questions": {
|
|
"team": {
|
|
"type": "choice",
|
|
"instructions": "Which team should handle this?",
|
|
"criteria": {"billing": "charges", "tech": None},
|
|
},
|
|
"billing": {"type": "noul", "instructions": "Is this about billing?"},
|
|
"urgency": {
|
|
"type": "score",
|
|
"instructions": "How urgent is this?",
|
|
"criteria": ["can wait", "this week", "today"],
|
|
},
|
|
"kind": {"type": "choice", "instructions": {"task": "Pick one"}, "criteria": ["refund", "question"]},
|
|
"sure": {"type": "noul", "instructions": "Is it sure?", "criteria": {"true": "certain", "false": None}},
|
|
"raw": {"type": "noul", "instructions": "Raw?", "criteria": {"true": "yes"}},
|
|
},
|
|
}
|
|
|
|
|
|
def test_headers(client: Build) -> None:
|
|
wire = Wire()
|
|
kai = client(wire, headers={"X-Team": "ops", "X-Trace": "client"})
|
|
kai.decide(
|
|
STATE,
|
|
QUESTIONS,
|
|
extra_headers={
|
|
"X-Trace": "call",
|
|
"Authorization": "Bearer stolen",
|
|
"User-Agent": "other",
|
|
"X-Kai-Runtime": "other",
|
|
"Accept": "text/html",
|
|
"X-Kai-Retry-Count": "7",
|
|
},
|
|
)
|
|
headers = wire.requests[0].headers
|
|
assert headers["authorization"] == f"Bearer {KEY}"
|
|
assert headers["accept"] == "application/json"
|
|
assert headers["content-type"] == "application/json"
|
|
assert headers["user-agent"] == f"hanzo-kai/{__version__}"
|
|
assert headers["x-kai-runtime"].startswith("cpython/") and " httpx/" in headers["x-kai-runtime"]
|
|
assert headers["x-team"] == "ops"
|
|
assert headers["x-trace"] == "call"
|
|
assert "x-kai-retry-count" not in headers
|
|
|
|
|
|
def test_every_answer_type(client: Build) -> None:
|
|
d = client(Wire(reply(headers={"x-request-id": "req-1"}))).decide(STATE, QUESTIONS)
|
|
assert isinstance(d, Decision)
|
|
assert (d.id, d.model, d.provider) == ("dec_0cfe54daad6b708eeebb91df8039aa96", "kai", "Hanzo")
|
|
assert d.usage.input_tokens == 150 and d.usage.output_tokens == 0
|
|
assert d.routing is not None and d.routing["checkpoint"] == "a7"
|
|
assert d.state_hash == BODY["state_hash"]
|
|
assert d.latency_ms == 102.466954
|
|
assert list(d.answers) == ["team", "billing", "urgency"]
|
|
|
|
team = d.choices["team"]
|
|
assert isinstance(team, ChoiceAnswer)
|
|
assert (team.type, team.choice, team.confidence, team.answer_confidence) == ("choice", "billing", 1.0, 1.0)
|
|
assert team.probabilities == {"billing": 1.0, "tech": 0.0}
|
|
|
|
billing = d.nouls["billing"]
|
|
assert isinstance(billing, NoulAnswer)
|
|
assert (billing.type, billing.noul, billing.answer_confidence) == ("noul", 0.746, 0.746)
|
|
|
|
urgency = d.scores["urgency"]
|
|
assert isinstance(urgency, ScoreAnswer)
|
|
assert (urgency.score, urgency.confidence, urgency.answer_confidence) == (1.4356, 0.2556, 0.5037)
|
|
assert urgency.legend == {0: "can wait", 1: "this week", 2: "today"}
|
|
assert urgency.probabilities == {0: 0.0304, 1: 0.5037, 2: 0.4659}
|
|
|
|
assert set(d.choices) == {"team"} and set(d.nouls) == {"billing"} and set(d.scores) == {"urgency"}
|
|
assert isinstance(d.raw_http_response, httpx.Response)
|
|
assert d.raw_http_response.json() == BODY
|
|
assert d.request_id == "req-1"
|
|
|
|
|
|
def test_unknown_answer_type_is_skipped(client: Build, caplog: pytest.LogCaptureFixture) -> None:
|
|
sent = body()
|
|
sent["answers"]["rank"] = {"type": "rank", "order": ["a", "b"]}
|
|
with caplog.at_level(logging.WARNING, logger="hanzo_kai"):
|
|
d = client(Wire(reply(json_body=sent))).decide(STATE, QUESTIONS)
|
|
assert list(d.answers) == ["team", "billing", "urgency"]
|
|
assert "rank" in caplog.text and "skipping answer" in caplog.text
|
|
assert d.raw_http_response.json()["answers"]["rank"]["type"] == "rank"
|
|
|
|
|
|
def test_extra_fields_are_kept(client: Build) -> None:
|
|
sent = body()
|
|
sent["answers"]["billing"]["action"] = "route"
|
|
sent["usage"]["cost"] = 0.0000031
|
|
sent["warnings"] = ["calibration is stale"]
|
|
d = client(Wire(reply(json_body=sent))).decide(STATE, QUESTIONS)
|
|
assert d.nouls["billing"].model_extra == {"action": "route"}
|
|
assert d.usage.model_extra == {"cost": 0.0000031}
|
|
assert d.model_extra == {"warnings": ["calibration is stale"]}
|
|
|
|
|
|
def test_sorted_keys_parse_by_name(client: Build) -> None:
|
|
levels = [f"level {n}" for n in range(11)]
|
|
sent = body()
|
|
sent["model"] = "hanzo/kai"
|
|
sent["answers"]["depth"] = {
|
|
"type": "score",
|
|
"score": 9.2,
|
|
"confidence": 0.8,
|
|
"legend": {str(n): text for n, text in enumerate(levels)},
|
|
"probabilities": {str(n): (0.9 if n == 10 else 0.01) for n in range(11)},
|
|
"answer_confidence": 0.9,
|
|
}
|
|
wire = Wire(reply(text=json.dumps(sent, sort_keys=True), headers={"content-type": "application/json"}))
|
|
d = client(wire).decide(
|
|
STATE, {**QUESTIONS, "depth": Score(instructions="How deep?", criteria=levels)}, model="hanzo/kai"
|
|
)
|
|
assert wire.json()["model"] == "hanzo/kai"
|
|
assert d.model == "hanzo/kai"
|
|
assert list(d.answers) == ["billing", "depth", "team", "urgency"]
|
|
assert d.choices["team"].choice == "billing"
|
|
assert list(d.scores["depth"].legend) == list(range(11))
|
|
assert list(d.scores["depth"].probabilities) == list(range(11))
|
|
assert d.scores["depth"].legend[10] == "level 10"
|
|
|
|
|
|
def test_model_and_extra_body(client: Build) -> None:
|
|
wire = Wire()
|
|
kai = client(wire, model="hanzo/kai")
|
|
kai.decide(STATE, QUESTIONS)
|
|
assert wire.json()["model"] == "hanzo/kai"
|
|
kai.decide(STATE, QUESTIONS, model="kai", extra_body={"session_id": "s1", "user": "u1", "trace": True})
|
|
sent = wire.json()
|
|
assert sent["model"] == "kai"
|
|
assert (sent["session_id"], sent["user"], sent["trace"]) == ("s1", "u1", True)
|
|
kai.decide(STATE, QUESTIONS, extra_body={"model": "override"})
|
|
assert wire.json()["model"] == "override"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"questions",
|
|
[
|
|
{},
|
|
[("q", Noul(instructions="x"))],
|
|
{"q": {"type": "score", "instructions": "x", "criteria": "low, high"}},
|
|
{"q": {"type": "score", "instructions": "x"}},
|
|
{"q": {"type": "choice", "instructions": "x", "criteria": "a or b"}},
|
|
{"q": {"type": "choice", "instructions": "x"}},
|
|
{"q": "Is this billing?"},
|
|
],
|
|
)
|
|
def test_client_checks(client: Build, questions: object) -> None:
|
|
wire = Wire()
|
|
with pytest.raises(KaiError):
|
|
client(wire).decide(STATE, questions)
|
|
assert wire.requests == []
|
|
|
|
|
|
def test_other_rules_are_the_servers(client: Build) -> None:
|
|
message = "question 'q': no 'instructions'; add the text the model should answer"
|
|
wire = Wire(reply(400, {"error": {"code": 400, "message": message}}))
|
|
kai = client(wire)
|
|
with pytest.raises(BadRequestError) as caught:
|
|
kai.decide("x", {"q": Noul(), "s": Score(criteria=[]), "r": {"type": "rank", "criteria": ["a"]}})
|
|
assert caught.value.message == message
|
|
assert wire.json()["questions"] == {
|
|
"q": {"type": "noul"},
|
|
"s": {"type": "score", "criteria": []},
|
|
"r": {"type": "rank", "criteria": ["a"]},
|
|
}
|
|
|
|
|
|
def test_question_models_validate() -> None:
|
|
with pytest.raises(ValidationError):
|
|
Score(instructions="x", criteria="low, high")
|
|
with pytest.raises(ValidationError):
|
|
Choice(instructions="x", criteria="a or b")
|
|
with pytest.raises(ValidationError):
|
|
Noul(instructions="x", criteria={"yes": "y"})
|
|
with pytest.raises(ValidationError):
|
|
Noul(instruction="typo")
|
|
with pytest.raises(ValidationError):
|
|
Score(instructions="x", criteria=["low", None])
|
|
question = Choice(instructions="x", criteria=["a", "b"])
|
|
with pytest.raises(ValidationError):
|
|
question.instructions = "y"
|
|
|
|
|
|
def test_state_forms(client: Build) -> None:
|
|
class Form(BaseModel):
|
|
subject: str
|
|
lines: tuple[str, ...]
|
|
|
|
wire = Wire()
|
|
kai = client(wire)
|
|
for state in ("plain text", ["a", {"b": None}], MappingProxyType({"k": (1, 2)}), Form(subject="s", lines=("x",))):
|
|
kai.decide(state, QUESTIONS)
|
|
assert [json.loads(r.content)["state"] for r in wire.requests] == [
|
|
"plain text",
|
|
["a", {"b": None}],
|
|
{"k": [1, 2]},
|
|
{"subject": "s", "lines": ["x"]},
|
|
]
|
|
for bad in ({"x": {1, 2}}, {"x": math.nan}, {"x": b"bytes"}):
|
|
with pytest.raises(KaiError, match="not JSON"):
|
|
kai.decide(bad, QUESTIONS)
|
|
assert len(wire.requests) == 4
|
|
|
|
|
|
class Ticket(Decision):
|
|
team: ChoiceAnswer
|
|
billing: NoulAnswer
|
|
missing: NoulAnswer | None = None
|
|
|
|
|
|
def test_response_model_decision(client: Build) -> None:
|
|
d = client(Wire(reply(headers={"x-request-id": "req-2"}))).decide(STATE, QUESTIONS, response_model=Ticket)
|
|
assert type(d) is Ticket
|
|
assert d.team.choice == "billing" and d.billing.noul == 0.746 and d.missing is None
|
|
assert d.scores["urgency"].score == 1.4356
|
|
assert d.request_id == "req-2"
|
|
assert "_response" not in d.model_dump()
|
|
|
|
|
|
def test_response_model_plain(client: Build) -> None:
|
|
class Spam(BaseModel):
|
|
noul: float
|
|
|
|
class Answers(BaseModel):
|
|
billing: Spam
|
|
|
|
class Plain(BaseModel):
|
|
model: str
|
|
answers: Answers
|
|
|
|
d = client(Wire()).decide(STATE, QUESTIONS, response_model=Plain)
|
|
assert type(d) is Plain
|
|
assert d.answers.billing.noul == 0.746
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("change", "path"),
|
|
[
|
|
(lambda b: b["answers"]["team"].pop("choice"), "answers.team.choice"),
|
|
(lambda b: b["answers"]["urgency"].update(probabilities={"high": 0.5}), "answers.urgency.probabilities.high"),
|
|
(lambda b: b["answers"].update(bad={"noul": 0.5}), "answers.bad.type"),
|
|
(lambda b: b.pop("id"), "id"),
|
|
],
|
|
)
|
|
def test_response_that_does_not_fit(client: Build, change: Callable[[dict[str, object]], object], path: str) -> None:
|
|
sent = body()
|
|
change(sent)
|
|
with pytest.raises(APIResponseValidationError) as caught:
|
|
client(Wire(reply(json_body=sent, headers={"x-request-id": "req-3"}))).decide(STATE, QUESTIONS)
|
|
assert caught.value.field_path == path
|
|
assert caught.value.status == 200
|
|
assert caught.value.body == sent
|
|
assert caught.value.request_id == "req-3"
|
|
|
|
|
|
def test_response_that_is_not_json(client: Build) -> None:
|
|
with pytest.raises(APIResponseValidationError) as caught:
|
|
client(Wire(reply(text="<html>gateway</html>"))).decide(STATE, QUESTIONS)
|
|
assert caught.value.field_path == ""
|
|
assert caught.value.body == "<html>gateway</html>"
|
|
|
|
|
|
def test_response_model_that_does_not_fit(client: Build) -> None:
|
|
class Strict(BaseModel):
|
|
missing: str
|
|
|
|
with pytest.raises(APIResponseValidationError) as caught:
|
|
client(Wire()).decide(STATE, QUESTIONS, response_model=Strict)
|
|
assert caught.value.field_path == "missing"
|
|
|
|
|
|
def test_decision_built_by_hand() -> None:
|
|
d = Decision(id="dec_1", model="kai")
|
|
assert d.answers == {} and d.usage.input_tokens is None and d.request_id is None
|
|
with pytest.raises(KaiError):
|
|
_ = d.raw_http_response
|
|
|
|
|
|
def test_noul_labels_reach_the_wire(client: Build) -> None:
|
|
wire = Wire()
|
|
labelled = Noul(instructions="Refund?", labels={"true": "refund", "false": "no refund"})
|
|
client(wire).decide(STATE, {"refund": labelled})
|
|
assert wire.json()["questions"]["refund"] == {
|
|
"type": "noul",
|
|
"instructions": "Refund?",
|
|
"labels": {"true": "refund", "false": "no refund"},
|
|
}
|
|
with pytest.raises(ValidationError):
|
|
Noul(labels={"yes": "refund", "no": "no refund"})
|
|
with pytest.raises(ValidationError):
|
|
Noul(labels={"true": "refund"})
|
|
|
|
|
|
def test_contract_answers(client: Build) -> None:
|
|
sent = body()
|
|
sent["answers"]["billing"] = {"type": "noul", "noul": 0.7461, "confidence": 0.4922, "answer_confidence": 0.7461}
|
|
sent["answers"]["team"]["probabilities"] = {"billing": 0.30000000000000004, "tech": 0.7}
|
|
d = client(Wire(reply(json_body=sent))).decide(STATE, QUESTIONS)
|
|
assert d.nouls["billing"].confidence == 0.4922
|
|
assert d.choices["team"].probabilities["billing"] == 0.30000000000000004
|
|
assert client(Wire()).decide(STATE, QUESTIONS).nouls["billing"].confidence is None
|