mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-23 20:23:00 +00:00
feat(hanzo-tools-llm): llm limits, searched models, served and paid-by, typed plan refusals
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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})
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user