feat(hanzo-tools-llm): llm limits, searched models, served and paid-by, typed plan refusals

This commit is contained in:
2026-10-04 16:50:55 -07:00
parent 10a9562e1d
commit 34082fa2ca
7 changed files with 690 additions and 69 deletions
+17 -4
View File
@@ -10,14 +10,27 @@ Hanzo credential `hanzo_tools.core.HanzoCloud` resolves (`HANZO_API_KEY`, then
- `query`: `POST /v1/chat/completions`. `model` defaults to `enso-auto`;
`"auto"` lets Enso pick across the models your org can serve. `max_cost`
(USD per 1,000 tokens) and `max_latency_ms` ride as `X-Max-Cost` and
`X-Max-Latency-Ms`. Returns `{id, model, content, finish_reason, usage}`.
- `models`: `GET /v1/models`, one row per model with family, class and the
catalog's per-million prices; `family` narrows it (`enso`, `kai`).
`X-Max-Latency-Ms`; `fallback: true` sends `X-Hanzo-Fallback: allow`, so the
plan's fallback answers instead of a refusal. Returns `{id, model, content,
finish_reason, usage, served, paid_by, usage_state, usage_class, fallback,
fallback_reason}`, the last six read from the `X-Hanzo-*` headers (`None` when
absent).
- `models`: `GET /v1/models`, one row per model with family, class, what it
supports, the catalog's per-million prices and `variable`; `search`, `class`
(`premium`, `ours`, `free`), `family` and `capability` narrow it.
- `limits`: `GET /v1/ai/limits` — plan, state, each class's percent, who pays,
resets, paused models and the actions. Shares only, never an amount.
- `feedback`: `POST /v1/ai/feedback` with `request_id`, `signal` and, for
`signal: rating`, a `rating` of 1 to 3.
- `kai_decide` — `POST /v1/decisions`: a `state` (text, object or array) and
named typed `questions` (`choice`, `score`, `noul`), answered by Kai with
calibrated probabilities.
calibrated probabilities; `model` is `kai`, a trained `kai-<id>` or
`typesafe/jev-1.13`.
A plan refusal (402 or 429 with `plan_allowance_used`, `paid_plan_required`,
`free_plan_cap`, `model_cap`, `usage_cap_exceeded` or `insufficient_balance`) is
an error whose `code` is that refusal, with `class`, `model`, `fallback`,
`window`, `resets_at` and `actions`; `hanzoai.usage` reads it.
## Install
+22 -8
View File
@@ -4,16 +4,19 @@ One state, any named typed questions, one call: the same tool, name and contract
as the TypeScript (src/tools/kai.ts) and Rust (rust/src/tools/kai_tool.rs)
runtimes. A question is {type, instructions, criteria}: ``choice`` picks a label,
``score`` an ordinal level, ``noul`` the probability a statement holds. Answers
come back with calibrated probabilities, and Kai writes no text.
come back with calibrated probabilities, and Kai writes no text. A plan refusal
comes back as an error naming its code and the actions (refusal.py).
"""
from typing import Any, ClassVar
from mcp.server.fastmcp import Context as MCPContext
from hanzo_tools.core import BaseTool, HanzoCloud, InvalidParamsError, ToolError
from hanzo_tools.core import BaseTool, ToolError, HanzoCloud, InvalidParamsError
from hanzo_tools.core.cloud import NO_KEY, CloudError
from .refusal import refused
#: The model a decision is asked of when none is named.
DEFAULT_MODEL = "kai"
KINDS = ("choice", "score", "noul")
@@ -81,7 +84,11 @@ def request(state: Any, questions: Any, model: Any = None) -> dict[str, Any]:
DECIDE_SCHEMA = {
"type": "object",
"properties": {
"state": {"type": ["string", "object", "array"], "items": {}, "description": "The case to decide about: text, a JSON object or an array"},
"state": {
"type": ["string", "object", "array"],
"items": {},
"description": "The case to decide about: text, a JSON object or an array",
},
"questions": {
"type": "object",
"description": "Question name → {type, instructions, criteria}; 1 to 100 questions",
@@ -89,7 +96,11 @@ DECIDE_SCHEMA = {
"type": "object",
"properties": {
"type": {"type": "string", "enum": list(KINDS)},
"instructions": {"type": ["string", "object", "array"], "items": {}, "description": "Optional, recommended. What Kai answers"},
"instructions": {
"type": ["string", "object", "array"],
"items": {},
"description": "Optional, recommended. What Kai answers",
},
"criteria": {
"type": ["object", "array"],
"items": {},
@@ -99,7 +110,10 @@ DECIDE_SCHEMA = {
"required": ["type"],
},
},
"model": {"type": "string", "description": "Decision model (default kai)"},
"model": {
"type": "string",
"description": "Decision model: kai (default), a trained kai-<id>, or typesafe/jev-1.13",
},
},
"required": ["state", "questions"],
}
@@ -109,7 +123,7 @@ class KaiDecideTool(BaseTool):
"""Typed questions to Kai about one case, answered with calibrated probabilities."""
name: ClassVar[str] = "kai_decide"
VERSION: ClassVar[str] = "0.2.1"
VERSION: ClassVar[str] = "0.2.2"
DEFAULT_ACTION: ClassVar[str] = "decide"
def __init__(self):
@@ -126,7 +140,7 @@ class KaiDecideTool(BaseTool):
try:
d = await self._cloud.post("/v1/decisions", body)
except CloudError as e:
raise ToolError(code="UPSTREAM", message=str(e))
raise refused(e)
if not isinstance(d, dict) or not isinstance(d.get("answers"), dict):
raise ToolError(code="UPSTREAM", message=f"not a decision: {str(d)[:200]}")
return d
@@ -144,5 +158,5 @@ class KaiDecideTool(BaseTool):
"optional; write it as a statement, not a bare yes/no question. Returns the decision {id, model, "
"answers: {name: answer}, usage, routing, state_hash, latency_ms}. Probabilities are calibrated; "
"confidence = (n·p_max − 1)/(n − 1) over n options. Billed on input tokens at the catalog's kai rate; "
"output_tokens is 0."
"output_tokens is 0. A refusal (402 or 429) is an error naming its code (free_plan_cap, ...) and its actions."
)
@@ -1,21 +1,27 @@
"""llm — models through Hanzo, one tool routed by action (HIP-0300).
query POST /v1/chat/completions one completion; Enso picks the model unless one is named
models GET /v1/models the catalog with each model's price, as the gateway states it
models GET /v1/models the catalog with each model's price, searched by text, class, family, capability
limits GET /v1/ai/limits where the plan stands: shares, states, resets and actions, never a figure
feedback POST /v1/ai/feedback how a routed answer went, so Enso learns from it
The same tool, actions and defaults as the TypeScript (src/tools/llm.ts) and Rust
(rust/src/tools/llm_tool.rs) runtimes of hanzo-mcp. Every call goes to
api.hanzo.ai through the one HanzoCloud client and its credential.
api.hanzo.ai through the one HanzoCloud client and its credential. A plan refusal
comes back as an error naming its code and the actions (refusal.py).
"""
from typing import Any, ClassVar
from collections.abc import Mapping
from mcp.server.fastmcp import Context as MCPContext
from hanzo_tools.core import BaseTool, HanzoCloud, InvalidParamsError, ToolError
from hanzoai.usage import read_usage
from hanzo_tools.core import BaseTool, ToolError, HanzoCloud, InvalidParamsError
from hanzo_tools.core.cloud import NO_KEY, CloudError
from .refusal import refused
#: Enso's managed default: the id Hanzo Dev sends when no model is named.
DEFAULT_MODEL = "enso-auto"
@@ -42,17 +48,58 @@ def bounds(max_cost: float | None, max_latency_ms: float | None) -> dict[str, st
return out
def catalog(body: Any, family: str | None = None) -> dict[str, Any]:
"""One row per model: family, class, outputs, context window and the price per
million tokens, as /v1/models states them and never restated here."""
#: The classes /v1/models sorts every model into.
CLASSES = ("premium", "ours", "free")
#: What a catalog row can say a model supports beyond text.
SUPPORTS = ("tools", "vision", "reasoning")
def _lower(v: Any) -> str:
return v.strip().lower() if isinstance(v, str) else ""
def capable(m: dict, c: str) -> bool:
"""Whether a row has a capability: tools, vision or reasoning, or a modality it
takes or makes (image, audio, embeddings, decision, transcript, ...)."""
if c in SUPPORTS:
return m.get(f"supports_{c}") is True
def has(v: Any) -> bool:
return isinstance(v, list) and any(str(x).lower() == c for x in v)
return has(m.get("inputs")) or has(m.get("outputs"))
def catalog(
body: Any,
family: str | None = None,
cls: str | None = None,
capability: str | None = None,
search: str | None = None,
) -> dict[str, Any]:
"""One row per model: family, class, what it supports and the price per million
tokens, as /v1/models states them and never restated here, narrowed by class,
family, capability and words that must all appear in its id, name, owner or
description. /v1/models takes no filter, so the narrowing is here."""
want_cls, want_family, want_cap = _lower(cls), _lower(family), _lower(capability)
words = _lower(search).split()
rows = []
if isinstance(body, dict):
rows = body.get("data") or body.get("models") or []
want = (family or "").lower()
models = []
for m in rows:
if want and str(m.get("family") or "").lower() != want:
if not isinstance(m, dict):
continue
if want_cls and _lower(m.get("class")) != want_cls:
continue
if want_family and _lower(m.get("family")) != want_family:
continue
if want_cap and not capable(m, want_cap):
continue
if words:
hay = " ".join(_lower(m.get(k)) for k in ("id", "name", "canonical_slug", "owned_by", "description"))
if not all(w in hay for w in words):
continue
pricing = m.get("pricing") or {}
models.append(
{
@@ -61,32 +108,111 @@ def catalog(body: Any, family: str | None = None) -> dict[str, Any]:
"class": m.get("class"),
"outputs": m.get("outputs"),
"context_window": m.get("context_window"),
"supports": [s for s in SUPPORTS if m.get(f"supports_{s}") is True],
"input_per_million": pricing.get("input_per_million"),
"output_per_million": pricing.get("output_per_million"),
# A router SKU is billed at the answering model's cost; its listed
# price is not what a call costs.
"variable": pricing.get("variable") is True,
}
)
return {"count": len(models), "models": models}
def _share(w: Any) -> dict[str, Any] | None:
"""The share fields of a usage window: percent, state, resets_at."""
if not isinstance(w, dict):
return None
return {"percent": w.get("percent"), "state": w.get("state"), "resets_at": w.get("resets_at")}
def standing(d: Any) -> dict[str, Any]:
"""Where the plan stands, field by field from /v1/ai/limits: shares (percent),
states, who pays, resets and actions. Only named fields are copied, so a figure
the answer might carry never reaches the result."""
d = d if isinstance(d, dict) else {}
classes = {
name: {**(_share(c) or {}), "paying": c.get("paying"), "window": _share(c.get("window"))}
for name, c in (d.get("classes") or {}).items()
if isinstance(c, dict)
}
lim = d.get("limited")
return {
"plan": d.get("plan"),
"state": d.get("state"),
"period_start": d.get("period_start"),
"period_end": d.get("period_end"),
"classes": classes,
"session": _share(d.get("session")),
"day": _share(d.get("day")),
"limited": {"reason": lim.get("reason"), "classes": lim.get("classes"), "message": lim.get("message")}
if isinstance(lim, dict)
else None,
"paused": [
{"model": p.get("model"), "fallback": p.get("fallback"), "resets_at": p.get("resets_at")}
for p in d.get("paused") or []
if isinstance(p, dict)
]
if isinstance(d.get("paused"), list)
else None,
"actions": [
{k: a.get(k) for k in ("kind", "label", "url", "plan", "model")}
for a in d.get("actions") or []
if isinstance(a, dict)
],
"upgrade": d.get("upgrade"),
"credits_after_allowance": d.get("credits_after_allowance"),
}
QUERY_SCHEMA = {
"type": "object",
"properties": {
"prompt": {"type": "string", "description": "query: the user message"},
"system": {"type": "string", "description": "query: an optional system message, with prompt"},
"messages": {"type": "array", "items": {"type": "object"}, "description": "query: the whole conversation as [{role, content}], instead of prompt"},
"model": {"type": "string", "description": f'query: a model id, "auto", or an enso id (enso-auto, enso-flash, enso-pro, enso-ultra, enso-free); default {DEFAULT_MODEL}'},
"messages": {
"type": "array",
"items": {"type": "object"},
"description": "query: the whole conversation as [{role, content}], instead of prompt",
},
"model": {
"type": "string",
"description": f'query: a model id, "auto", or an enso id (enso-auto, enso-flash, enso-pro, enso-ultra, enso-free); default {DEFAULT_MODEL}',
},
"max_tokens": {"type": "integer", "description": "query: the most tokens to generate"},
"temperature": {"type": "number", "description": "query: sampling temperature"},
"max_cost": {"type": "number", "description": "query: the most you will pay, in USD per 1,000 tokens (X-Max-Cost)"},
"max_latency_ms": {"type": "number", "description": "query: the slowest model you will accept, in milliseconds (X-Max-Latency-Ms)"},
"max_cost": {
"type": "number",
"description": "query: the most you will pay, in USD per 1,000 tokens (X-Max-Cost)",
},
"max_latency_ms": {
"type": "number",
"description": "query: the slowest model you will accept, in milliseconds (X-Max-Latency-Ms)",
},
"fallback": {
"type": "boolean",
"description": "query: when the plan refuses the model, let its fallback answer instead (X-Hanzo-Fallback: allow)",
},
},
"required": [],
}
MODELS_SCHEMA = {
"type": "object",
"properties": {"family": {"type": "string", "description": "models: only this family, e.g. enso or kai"}},
"properties": {
"search": {
"type": "string",
"description": "models: words that must all appear in the id, name, owner or description",
},
"class": {"type": "string", "enum": list(CLASSES), "description": "models: only this class"},
"family": {"type": "string", "description": "models: only this family, e.g. enso, zen or kai"},
"capability": {
"type": "string",
"description": "models: tools, vision, reasoning, or a modality taken or made (image, audio, embeddings, rerank, transcript, decision)",
},
},
"required": [],
}
LIMITS_SCHEMA = {"type": "object", "properties": {}, "required": []}
FEEDBACK_SCHEMA = {
"type": "object",
"properties": {
@@ -102,8 +228,10 @@ class UnifiedLLMTool(BaseTool):
"""Models through api.hanzo.ai: completions routed by Enso, the catalog, feedback."""
name: ClassVar[str] = "llm"
VERSION: ClassVar[str] = "0.2.1"
VERSION: ClassVar[str] = "0.2.2"
DEFAULT_ACTION: ClassVar[str] = "query"
# `class` is a keyword in Python; models takes it as `cls`.
PARAM_ALIASES: ClassVar[dict[str, str]] = {"class": "cls"}
def __init__(self):
super().__init__()
@@ -114,12 +242,23 @@ class UnifiedLLMTool(BaseTool):
def description(self) -> str:
return (
"Models through Hanzo. "
"query (default): one completion; model defaults to enso-auto, and \"auto\" lets Enso, "
'query (default): one completion; model defaults to enso-auto, and "auto" lets Enso, '
"the router, pick across the models your org can serve; max_cost (USD per 1,000 tokens) "
"and max_latency_ms bound the pick; returns {id, model, content, finish_reason, usage}, "
"where model is the one that served and id is what feedback takes. "
"models: the catalog with each model's family, class and price per million input and "
"output tokens (family filters, e.g. enso or kai). "
"and max_latency_ms bound the pick; fallback true lets another model answer when the plan "
"refuses the one named. Returns {id, model, content, finish_reason, usage, served, paid_by, "
"usage_state, usage_class, fallback, fallback_reason}: model is the one Enso routed to, "
"served the SKU that answered, paid_by plan, credits or free, id is what feedback takes. "
"models: the catalog with each model's family, class (premium, ours, free), what it "
"supports and price per million input and output tokens; variable true means a router "
"billed at the answering model's cost. search (words in id, name, owner or description), "
"class, family and capability (tools, vision, reasoning, or a modality such as image, "
"audio, embeddings) narrow it. "
"limits: where your plan stands: its state (ok, near, limited), each class's percent used, "
"who pays, when it resets, paused models and their fallbacks, and the actions (upgrade, "
"credits, topup) with their links. "
"A refusal (402 or 429) is an error naming its code (plan_allowance_used, "
"paid_plan_required, free_plan_cap, model_cap, usage_cap_exceeded, insufficient_balance) "
"and its actions. "
"feedback: tell Enso how a routed answer went: request_id is the completion id, signal one of "
+ ", ".join(SIGNALS)
+ ", and rating 1 to 3 with signal rating. Typed decisions are kai_decide's, not a completion's."
@@ -132,14 +271,16 @@ class UnifiedLLMTool(BaseTool):
raise ToolError(code="INVALID_PARAMS", message=NO_KEY)
return self._cloud
async def _send(self, method: str, path: str, body: dict | None = None, headers: dict | None = None) -> Any:
async def _send(
self, method: str, path: str, body: dict | None = None, headers: dict | None = None
) -> tuple[Any, Mapping[str, str]]:
cloud = self._get_cloud()
try:
if method == "GET":
return await cloud.get(path)
return await cloud.post(path, body, headers=headers)
return await cloud.send(method, path)
return await cloud.send(method, path, headers=headers, json=body or {})
except CloudError as e:
raise ToolError(code="UPSTREAM", message=str(e))
raise refused(e)
def _register_llm_actions(self):
@self.action("query", "One completion; Enso routes it unless a model is named", schema=QUERY_SCHEMA)
@@ -153,6 +294,7 @@ class UnifiedLLMTool(BaseTool):
temperature: float | None = None,
max_cost: float | None = None,
max_latency_ms: float | None = None,
fallback: bool | None = None,
) -> dict:
if messages is None:
if not prompt:
@@ -163,26 +305,53 @@ class UnifiedLLMTool(BaseTool):
elif not isinstance(messages, list) or not messages:
raise InvalidParamsError("messages must be a non-empty array of {role, content}", param="messages")
headers = bounds(max_cost, max_latency_ms)
if fallback is True:
headers["X-Hanzo-Fallback"] = "allow"
body: dict[str, Any] = {"model": model or DEFAULT_MODEL, "messages": messages}
if max_tokens is not None:
body["max_tokens"] = max_tokens
if temperature is not None:
body["temperature"] = temperature
data = await self._send("POST", "/v1/chat/completions", body, headers)
data, got = await self._send("POST", "/v1/chat/completions", body, headers)
choice = (data.get("choices") or [{}])[0]
u = read_usage(got)
return {
"id": data.get("id"),
# The model that served. Enso also names it in X-Routed-Model; the
# body's model always says the same.
"model": data.get("model"),
# The model that served: Enso names it in X-Routed-Model when it
# rewrote the request, and the body's model always says the same.
"model": got.get("x-routed-model") or data.get("model"),
"content": (choice.get("message") or {}).get("content"),
"finish_reason": choice.get("finish_reason"),
"usage": data.get("usage"),
# Who answered and who paid, as the gateway says on the response; a
# header it did not send is None (a free model sends only served).
"served": u.served,
"paid_by": u.paid_by,
"usage_state": u.usage,
"usage_class": u.usage_class,
"fallback": u.fallback,
"fallback_reason": u.reason,
}
@self.action("models", "The catalog with each model's family, class and per-million price", schema=MODELS_SCHEMA)
async def models(ctx: MCPContext, family: str | None = None) -> dict:
return catalog(await self._send("GET", "/v1/models"), family)
@self.action(
"models", "The catalog with each model's class and per-million price, searched", schema=MODELS_SCHEMA
)
async def models(
ctx: MCPContext,
search: str | None = None,
cls: str | None = None,
family: str | None = None,
capability: str | None = None,
) -> dict:
if _lower(cls) and _lower(cls) not in CLASSES:
raise InvalidParamsError(f"class must be one of {', '.join(CLASSES)}", param="class")
data, _ = await self._send("GET", "/v1/models")
return catalog(data, family=family, cls=cls, capability=capability, search=search)
@self.action("limits", "Where your plan stands: shares, states, resets and actions", schema=LIMITS_SCHEMA)
async def limits(ctx: MCPContext) -> dict:
data, _ = await self._send("GET", "/v1/ai/limits")
return standing(data)
@self.action("feedback", "Tell Enso how a routed answer went", schema=FEEDBACK_SCHEMA)
async def feedback(
@@ -192,7 +361,9 @@ class UnifiedLLMTool(BaseTool):
rating: int | None = None,
) -> dict:
if not request_id:
raise InvalidParamsError("request_id required: the id of the completion (chatcmpl-...)", param="request_id")
raise InvalidParamsError(
"request_id required: the id of the completion (chatcmpl-...)", param="request_id"
)
if signal not in SIGNALS:
raise InvalidParamsError(f"signal must be one of {', '.join(SIGNALS)}", param="signal")
body: dict[str, Any] = {"request_id": request_id, "signal": signal}
@@ -200,7 +371,7 @@ class UnifiedLLMTool(BaseTool):
if rating not in (1, 2, 3) or isinstance(rating, bool):
raise InvalidParamsError("rating must be 1, 2 or 3 when signal is rating", param="rating")
body["rating"] = int(rating)
data = await self._send("POST", "/v1/ai/feedback", body)
data, _ = await self._send("POST", "/v1/ai/feedback", body)
# The /v1 envelope can carry a refusal under a 200.
if isinstance(data, dict) and data.get("status") == "error":
raise ToolError(code="UPSTREAM", message=data.get("msg") or "feedback refused")
@@ -0,0 +1,38 @@
"""A failed cloud call as a tool error; a plan refusal names its code and the ways on.
api.hanzo.ai refuses what the plan, a cap or the balance will not pay for with a
402 or 429 whose ``error.code`` names why. ``hanzoai.usage.refusal`` reads it, the
same reader the SDK raises its typed errors from, and the error leads with that
code, as the TypeScript and Rust runtimes' do (src/tools/refusal.ts). It carries
what the server offers: the class, the capped model and its fallback, the spent
window, when it resets, and the actions (upgrade, switch, credits, topup). The
message is the server's own sentence, which names no figure; nothing else in the
body is passed on, so no amount, count or cap reaches a tool result from here.
"""
from typing import Any
from hanzoai.wire import Reply, stamp
from hanzoai.usage import refusal
from hanzo_tools.core import ToolError
from hanzo_tools.core.cloud import CloudError
def refused(e: CloudError) -> ToolError:
"""The ToolError for a failed call: the refusal's own code when it is one, else UPSTREAM."""
r = refusal(Reply(status=e.status or 0, body=e.body))
if r is None:
return ToolError(code="UPSTREAM", message=str(e))
actions: list[dict[str, Any]] = [{k: v for k, v in vars(a).items() if v} for a in r.actions if a.kind]
if r.upgrade_url and not any(a.get("url") == r.upgrade_url for a in actions):
actions.insert(0, {"kind": "upgrade", "url": r.upgrade_url})
details = {
"status": r.status,
"class": r.usage_class,
"model": r.model,
"fallback": r.fallback,
"window": r.window,
"resets_at": stamp(r.resets_at) or None,
"actions": actions,
}
return ToolError(code=r.code, message=r.message, details={k: v for k, v in details.items() if v is not None})
+6 -3
View File
@@ -4,15 +4,18 @@ build-backend = "setuptools.build_meta"
[project]
name = "hanzo-tools-llm"
version = "0.2.1"
description = "Models through Hanzo as MCP tools: llm (Enso routing, the catalog, feedback) and kai_decide (Kai decisions)"
version = "0.2.2"
description = "Models through Hanzo as MCP tools: llm (Enso routing, the catalog, plan limits, feedback) and kai_decide (Kai decisions)"
readme = "README.md"
requires-python = ">=3.12"
license = { text = "MIT" }
authors = [{ name = "Hanzo Industries Inc", email = "[email protected]" }]
keywords = ["hanzo", "tools", "llm", "mcp", "ai"]
dependencies = [
"hanzo-tools>=0.3.8",
# 8.5.624 is the first hanzoai carrying hanzoai.usage (refusal, read_usage)
"hanzoai>=8.5.624",
# 0.3.9: a CloudError carries the refusal's body and headers; send() returns headers
"hanzo-tools>=0.3.9",
"httpx>=0.27.0",
"mcp>=1.25.0",
]
+66
View File
@@ -0,0 +1,66 @@
"""The two tools against api.hanzo.ai, with the credential HanzoCloud resolves.
Skipped when no credential resolves (HANZO_API_KEY, ~/.hanzo/config.json, or a
signed-in `hanzo` CLI). The premium query spends a few tokens of credit.
"""
import json
import asyncio
import pytest
from hanzo_tools.llm import KaiDecideTool, UnifiedLLMTool
from hanzo_tools.core import HanzoCloud
pytestmark = [
pytest.mark.integration,
pytest.mark.skipif(not HanzoCloud().configured(), reason="no Hanzo credential"),
]
def run(tool, **kw):
async def call():
try:
return await tool.call(None, **kw)
finally:
if tool._cloud is not None:
await tool._cloud.aclose()
return asyncio.run(call())
def test_live_limits_states_shares_and_never_a_figure():
env = run(UnifiedLLMTool(), action="limits")
assert env["ok"], env
d = env["data"]
assert d["state"] in ("ok", "near", "limited") and d["plan"]
assert all(set(a) == {"kind", "label", "url", "plan", "model"} for a in d["actions"])
assert "$" not in json.dumps(d)
def test_live_models_by_class_carry_their_price():
env = run(UnifiedLLMTool(), action="models", **{"class": "ours"}, family="kai")
assert env["ok"], env
kai = {m["id"]: m for m in env["data"]["models"]}["kai"]
assert (kai["class"], kai["input_per_million"]) == ("ours", 0.021)
def test_live_a_premium_query_says_credits_paid_or_names_the_empty_wallet():
env = run(UnifiedLLMTool(), prompt="Say ok", model="anthropic/claude-sonnet-4.5", max_tokens=5)
if env["ok"]:
d = env["data"]
assert (d["served"], d["usage_class"], d["paid_by"]) == ("anthropic/claude-sonnet-4.5", "premium", "credits")
else:
assert (env["error"]["code"], env["error"]["status"]) == ("insufficient_balance", 402), env
def test_live_kai_decides_or_names_its_refusal():
env = run(
KaiDecideTool(),
state="I was charged twice for one order",
questions={"team": {"type": "choice", "criteria": ["payments", "account"]}},
)
if env["ok"]:
assert env["data"]["answers"]["team"]["choice"] in ("payments", "account")
else:
e = env["error"]
assert e["code"] == "free_plan_cap" and e["class"] == "ours" and e["resets_at"]
+338 -22
View File
@@ -6,35 +6,144 @@ out (method, path, bearer, Enso's headers, body) and what the tool made of the
answer. No socket is opened.
"""
import asyncio
import json
import asyncio
import httpx
import pytest
from hanzo_tools.core import HanzoCloud
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."}}],
"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": "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}},
"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},
}
@@ -44,21 +153,30 @@ class Gateway:
def __init__(self):
self.seen: list[httpx.Request] = []
self.answer = None # (status, body) to force one reply
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 = self.answer
return httpx.Response(status, json=body)
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={
"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
@@ -101,7 +219,16 @@ def test_query_defaults_to_enso_auto_and_sends_no_unasked_bound():
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)
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) == {
@@ -109,7 +236,61 @@ def test_query_carries_auto_and_the_bounds_and_returns_the_model_that_served():
"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"]}
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():
@@ -135,8 +316,116 @@ 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, "input_per_million": 0.021, "output_per_million": 0}]}
assert run(tool, action="models")["data"]["count"] == 3
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():
@@ -149,8 +438,14 @@ def test_feedback_posts_the_signal_and_returns_what_was_recorded():
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 (
"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")
@@ -159,7 +454,13 @@ def test_feedback_refuses_what_the_server_would_and_reads_an_error_envelope():
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"}}}
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
@@ -186,6 +487,21 @@ def test_kai_decide_names_each_shape_the_contract_refuses(question, said):
def test_kai_decide_carries_the_plan_cap_refusal():
tool, gw = wired(KaiDecideTool)
gw.answer = (429, {"error": {"message": "Free plan: today's Kai requests are used.", "code": "free_plan_cap"}})
env = run(tool, state="s", questions={"q": {"type": "noul"}})
assert not env["ok"] and "429" in env["error"]["message"] and "free_plan_cap" in env["error"]["message"]
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"