feat(rust): llm limits, catalog search, served and paid-by, plan refusals

This commit is contained in:
2026-10-04 16:28:31 -07:00
parent 99809579e6
commit e3067ebc8e
6 changed files with 455 additions and 31 deletions
+7 -4
View File
@@ -70,8 +70,10 @@ three, like `tracker_*`, are TypeScript only, and Python `hanzo-mcp` carries non
## `llm` — models, limits, who paid
`llm` (`src/tools/llm.ts`) is the AI plane by action over `api.hanzo.ai`, with
the same bearer as `kai_*`:
`llm` (`src/tools/llm.ts`, Rust `rust/src/tools/llm_tool.rs`) is the AI plane
by action over `api.hanzo.ai`, with the same bearer as `kai_*`. Both runtimes
answer the four actions below the same way; Rust also keeps `consensus` and
`list`, which TypeScript serves from `think`:
| action | route | answers |
|---|---|---|
@@ -100,7 +102,8 @@ way in to `/v1/decisions`.
resets_at, actions), so an amount, count or cap in an answer never reaches a
result. `PUT /v1/ai/limits` is not offered here.
**Refusals** (`src/tools/refusal.ts`, shared by `llm` and `kai_*`): a 402 or 429
**Refusals** (`src/tools/refusal.ts`, Rust `hanzo_api::refusal`; shared by `llm`
and `kai_*`): a 402 or 429
whose code is `plan_allowance_used`, `paid_plan_required`, `free_plan_cap`,
`model_cap`, `usage_cap_exceeded` or `insufficient_balance` is the error result
`{"error": {status, code, message, class?, model?, fallback?, window?,
@@ -109,7 +112,7 @@ resets_at?, actions}}`. Clients switch on the code (hanzoai/ai
figure. Actions keep kind, label, url, plan and model. `limit`, the name of the
spent window, is passed on as `window`. The gate's `{error: {...}}` and a
controller's `{status: "error", msg, code}` read the same. Any other non-2xx
stays `<status>: <sentence>`.
stays `<status>: <sentence>` (in Rust, the `UPSTREAM` envelope).
## `research` — one door, one mode
+124
View File
@@ -126,6 +126,9 @@ impl HanzoApi {
let headers = resp.headers().clone();
let text = resp.text().await?;
let parsed = serde_json::from_str::<Value>(&text).ok();
if let Some(r) = refusal(status, parsed.as_ref()) {
return Err(r.into());
}
let refused = parsed.as_ref().is_some_and(|b| b["status"] == "error");
if !(200..300).contains(&status) || refused {
return Err(anyhow!("{}: {}", status, reason(parsed.as_ref(), &text)));
@@ -153,6 +156,86 @@ impl HanzoApi {
}
}
/// The codes a plan refusal carries. Clients switch on these, never on the
/// message (hanzoai/ai `object.LimitHit`, `object.CodeInsufficientBalance`).
pub const REFUSAL_CODES: &[&str] = &[
"plan_allowance_used",
"paid_plan_required",
"free_plan_cap",
"model_cap",
"usage_cap_exceeded",
"insufficient_balance",
];
/// A plan refusal: a 402 or 429 whose code says the plan, a cap or the balance
/// declined the request. `error` is what a tool result carries — the status,
/// the code, the server's own sentence (which names no figure), the class, the
/// capped model and its fallback, the spent window, when it resets, and the
/// actions with their links — and nothing else from the body, so no amount,
/// count or cap reaches a result.
#[derive(Debug)]
pub struct Refusal {
pub error: Value,
}
impl std::fmt::Display for Refusal {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{} {}", self.error["status"], self.error["code"].as_str().unwrap_or_default())
}
}
impl std::error::Error for Refusal {}
/// Read a non-2xx answer as a plan refusal, or `None` when it is any other error.
/// The gate answers `{error: {message, code, ...}}`; a controller answers the
/// `/v1` envelope `{status: "error", msg, code}`. Both read the same.
pub fn refusal(status: u16, body: Option<&Value>) -> Option<Refusal> {
if status != 402 && status != 429 {
return None;
}
let body = body?;
let e = if body["error"].is_object() {
body["error"].clone()
} else if body["status"] == "error" {
json!({ "code": body["code"], "message": body["msg"] })
} else {
return None;
};
let code = e["code"].as_str().filter(|c| REFUSAL_CODES.contains(c))?.to_string();
let text = |v: &Value| v.as_str().filter(|s| !s.is_empty()).map(str::to_string);
let mut actions: Vec<Value> = e["actions"]
.as_array()
.map(|a| {
a.iter()
.filter(|x| text(&x["kind"]).is_some())
.map(|x| {
let kept: serde_json::Map<String, Value> = ["kind", "label", "url", "plan", "model"]
.iter()
.filter_map(|k| text(&x[*k]).map(|v| (k.to_string(), json!(v))))
.collect();
Value::Object(kept)
})
.collect()
})
.unwrap_or_default();
if let Some(up) = text(&e["upgrade_url"]) {
if !actions.iter().any(|a| a["url"] == up.as_str()) {
actions.insert(0, json!({ "kind": "upgrade", "url": up }));
}
}
let mut error = serde_json::Map::new();
error.insert("status".into(), json!(status));
error.insert("code".into(), json!(code));
// `limit` names the window that is spent (session or day), never its size.
for (from, to) in [("message", "message"), ("class", "class"), ("model", "model"), ("fallback", "fallback"), ("limit", "window"), ("resets_at", "resets_at")] {
if let Some(v) = text(&e[from]) {
error.insert(to.into(), json!(v));
}
}
error.insert("actions".into(), Value::Array(actions));
Some(Refusal { error: Value::Object(error) })
}
/// A 2xx answer: the parsed body and the response headers.
pub struct Answer {
pub body: Value,
@@ -275,6 +358,47 @@ mod tests {
assert_eq!(DEFAULT_BASE_URL, "https://api.hanzo.ai");
}
#[test]
fn a_plan_refusal_keeps_the_code_the_sentence_and_the_actions_only() {
let body = json!({ "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", "price_cents": 2000 },
{ "kind": "topup", "label": "Add prepaid credit", "url": "https://hanzo.ai/pay" }
],
"used": 50, "cap": 50
} });
let r = refusal(429, Some(&body)).expect("a refusal");
assert_eq!(r.error, json!({
"status": 429, "code": "free_plan_cap",
"message": "Free plan: today's Kai requests are used. Upgrade for more: https://hanzo.ai/pay",
"class": "ours", "resets_at": "2026-10-05T00:00:00Z",
"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" }
]
}));
}
#[test]
fn a_window_and_an_envelope_refusal_read_the_same_way() {
let w = refusal(429, Some(&json!({ "error": { "message": "m", "code": "usage_cap_exceeded", "limit": "day" } }))).unwrap();
assert_eq!(w.error["window"], "day");
let e = refusal(402, Some(&json!({ "status": "error", "msg": "Insufficient balance.", "code": "insufficient_balance" }))).unwrap();
assert_eq!(e.error, json!({ "status": 402, "code": "insufficient_balance", "message": "Insufficient balance.", "actions": [] }));
}
#[test]
fn other_answers_are_not_refusals() {
let pool = json!({ "error": { "message": "busy", "code": "pool_busy" } });
assert!(refusal(429, Some(&pool)).is_none());
let cap = json!({ "error": { "code": "free_plan_cap" } });
assert!(refusal(400, Some(&cap)).is_none());
assert!(refusal(402, None).is_none());
}
#[test]
fn frames_decode_in_order_and_drop_the_done_sentinel() {
let body = concat!(
+2 -2
View File
@@ -10,7 +10,7 @@ use anyhow::Result;
use async_trait::async_trait;
use serde_json::{json, Value};
use super::{envelope_err, envelope_ok};
use super::{envelope_err, envelope_ok, refused};
use crate::hanzo_api::HanzoApi;
use crate::{MCPTool, ToolResult};
@@ -166,7 +166,7 @@ impl MCPTool for KaiDecideTool {
Ok(match self.api.call(reqwest::Method::POST, "/v1/decisions", Some(body), &[]).await {
Ok(a) if a.body["answers"].is_object() => ToolResult::ok(envelope_ok("kai_decide", "decide", a.body)),
Ok(a) => ToolResult::ok(envelope_err("kai_decide", "decide", "UPSTREAM", format!("not a decision: {}", a.body))),
Err(e) => ToolResult::ok(envelope_err("kai_decide", "decide", "UPSTREAM", e.to_string())),
Err(e) => refused("kai_decide", "decide", &e).unwrap_or_else(|| ToolResult::ok(envelope_err("kai_decide", "decide", "UPSTREAM", e.to_string()))),
})
}
}
+153 -19
View File
@@ -10,9 +10,14 @@
//! `max_cost` / `max_latency_ms` ride as its routing headers
//! - `consensus` → many models in parallel, a judge model synthesizes them
//! - `list` → the built-in default model set (no network)
//! - `models` → GET /v1/models: each model's family, class and price
//! - `models` → GET /v1/models: each model's family, class, supports and price,
//! searched by text, class, family and capability
//! - `limits` → GET /v1/ai/limits: where the plan stands, never a figure
//! - `feedback` → POST /v1/ai/feedback: how a routed answer went
//!
//! A plan refusal (402/429 with a code) is an MCP error result naming the code
//! and the actions (`hanzo_api::refusal`).
//!
//! The chat / embed / consensus helpers are `pub` so the `think` tool composes
//! its consensus/agent/embed actions over the same seam — one way to reach the
//! LLM, no duplicate HTTP.
@@ -22,13 +27,17 @@ use async_trait::async_trait;
use serde::Deserialize;
use serde_json::{json, Value};
use super::{envelope_err, envelope_ok};
use super::{envelope_err, envelope_ok, refused};
use crate::hanzo_api::HanzoApi;
use crate::{MCPTool, ToolResult};
/// Default single-query model: Enso's managed default, the id Hanzo Dev sends
/// when no model is named.
pub const DEFAULT_MODEL: &str = "enso-auto";
/// The classes the catalog sorts models into.
pub const CLASSES: &[&str] = &["premium", "ours", "free"];
/// What a catalog row's `supports_*` flags name.
pub const SUPPORTS: &[&str] = &["tools", "vision", "reasoning"];
/// The signals `/v1/ai/feedback` takes.
pub const SIGNALS: &[&str] = &["up", "accept", "regenerate", "down", "switch", "abandon", "revert", "rating", "dismiss"];
/// Default aggregator/judge for consensus synthesis.
@@ -170,15 +179,51 @@ pub async fn run_consensus(
Ok(data)
}
/// The catalog as one row per model: its family, class, outputs, context window
/// and price per million tokens, as `/v1/models` states them and never restated
/// here. `family` narrows to one family (enso, kai, ...).
pub fn catalog(body: &Value, family: Option<&str>) -> Value {
/// How `models` narrows the catalog: words that must all appear in the id,
/// name, owner or description, a class, a family, and a capability.
#[derive(Debug, Default)]
pub struct Filter {
pub search: Option<String>,
pub class: Option<String>,
pub family: Option<String>,
pub capability: Option<String>,
}
fn lower(v: &Value) -> String {
v.as_str().map(|s| s.trim().to_lowercase()).unwrap_or_default()
}
fn norm(v: &Option<String>) -> Option<String> {
v.as_deref().map(|s| s.trim().to_lowercase()).filter(|s| !s.is_empty())
}
/// Whether a row has a capability: tools, vision or reasoning (its `supports_*`
/// flag), or a modality it takes or makes (image, audio, embeddings, decision, ...).
fn capable(m: &Value, c: &str) -> bool {
if SUPPORTS.contains(&c) {
return m[format!("supports_{c}")] == true;
}
let has = |v: &Value| v.as_array().is_some_and(|a| a.iter().any(|x| lower(x) == c));
has(&m["inputs"]) || has(&m["outputs"])
}
/// The catalog as one row per model: its family, class, outputs, context window,
/// what it supports, price per million tokens as `/v1/models` states it and never
/// restated here, and `variable` for a router SKU billed at the answering model's
/// cost. `filter` narrows it.
pub fn catalog(body: &Value, filter: &Filter) -> Value {
let rows = body["data"].as_array().or_else(|| body["models"].as_array()).cloned().unwrap_or_default();
let family = family.map(str::to_lowercase).filter(|f| !f.is_empty());
let (class, family, capability) = (norm(&filter.class), norm(&filter.family), norm(&filter.capability));
let words: Vec<String> = norm(&filter.search).map(|s| s.split_whitespace().map(str::to_string).collect()).unwrap_or_default();
let models: Vec<Value> = rows
.iter()
.filter(|m| family.as_deref().is_none_or(|f| m["family"].as_str().map(str::to_lowercase).as_deref() == Some(f)))
.filter(|m| class.as_deref().is_none_or(|c| lower(&m["class"]) == c))
.filter(|m| family.as_deref().is_none_or(|f| lower(&m["family"]) == f))
.filter(|m| capability.as_deref().is_none_or(|c| capable(m, c)))
.filter(|m| {
let hay = ["id", "name", "canonical_slug", "owned_by", "description"].iter().map(|k| lower(&m[*k])).collect::<Vec<_>>().join(" ");
words.iter().all(|w| hay.contains(w.as_str()))
})
.map(|m| {
json!({
"id": m["id"],
@@ -186,14 +231,69 @@ pub fn catalog(body: &Value, family: Option<&str>) -> Value {
"class": m["class"],
"outputs": m["outputs"],
"context_window": m["context_window"],
"supports": SUPPORTS.iter().filter(|s| m[format!("supports_{s}")] == true).collect::<Vec<_>>(),
"input_per_million": m["pricing"]["input_per_million"],
"output_per_million": m["pricing"]["output_per_million"],
"variable": m["pricing"]["variable"] == true,
})
})
.collect();
json!({ "count": models.len(), "models": models })
}
/// Where the caller's 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 can never reach the result.
pub fn standing(d: &Value) -> Value {
fn share(w: &Value) -> Value {
if !w.is_object() {
return Value::Null;
}
json!({ "percent": w["percent"], "state": w["state"], "resets_at": w["resets_at"] })
}
let mut classes = serde_json::Map::new();
for (name, c) in d["classes"].as_object().cloned().unwrap_or_default() {
let mut row = share(&c);
row["paying"] = c["paying"].clone();
if c["window"].is_object() {
row["window"] = share(&c["window"]);
}
classes.insert(name, row);
}
let actions: Vec<Value> = d["actions"]
.as_array()
.map(|a| a.iter().map(|x| json!({ "kind": x["kind"], "label": x["label"], "url": x["url"], "plan": x["plan"], "model": x["model"] })).collect())
.unwrap_or_default();
let mut out = json!({
"plan": d["plan"],
"state": d["state"],
"period_start": d["period_start"],
"period_end": d["period_end"],
"classes": classes,
"session": share(&d["session"]),
"day": share(&d["day"]),
"actions": actions,
"upgrade": d["upgrade"],
"credits_after_allowance": d["credits_after_allowance"],
});
if d["limited"].is_object() {
out["limited"] = json!({ "reason": d["limited"]["reason"], "classes": d["limited"]["classes"], "message": d["limited"]["message"] });
}
if let Some(p) = d["paused"].as_array() {
out["paused"] = p.iter().map(|x| json!({ "model": x["model"], "fallback": x["fallback"], "resets_at": x["resets_at"] })).collect();
}
strip_nulls(out)
}
/// Drop null members, so an absent field reads absent rather than null.
fn strip_nulls(v: Value) -> Value {
match v {
Value::Object(o) => Value::Object(o.into_iter().filter(|(_, v)| !v.is_null()).map(|(k, v)| (k, strip_nulls(v))).collect()),
Value::Array(a) => Value::Array(a.into_iter().map(strip_nulls).collect()),
other => other,
}
}
// ---------------------------------------------------------------------------
// llm tool — action-routed dyn-trait tool
// ---------------------------------------------------------------------------
@@ -223,6 +323,12 @@ struct LlmArgs {
/// Milliseconds — `X-Max-Latency-Ms`.
max_latency_ms: Option<f64>,
family: Option<String>,
/// models: words in the id, name, owner or description.
search: Option<String>,
class: Option<String>,
capability: Option<String>,
/// query: let a refused model's fallback answer (`X-Hanzo-Fallback: allow`).
fallback: Option<bool>,
request_id: Option<String>,
signal: Option<String>,
rating: Option<f64>,
@@ -253,13 +359,13 @@ impl LlmTool {
pub fn schema() -> Value {
json!({
"name": "llm",
"description": "Models through api.hanzo.ai. 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. consensus: several models and a judge. list: the defaults, offline. models: the catalog with each model's family, class and price per million input and output tokens (family filters, e.g. enso or kai). feedback: tell Enso how a routed answer went: request_id is the completion id, signal one of up, accept, regenerate, down, switch, abandon, revert, rating, dismiss, and rating 1 to 3 with signal rating.",
"description": "Models through api.hanzo.ai. 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; 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. consensus: several models and a judge. list: the defaults, offline. 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 up, accept, regenerate, down, switch, abandon, revert, rating, dismiss, and rating 1 to 3 with signal rating.",
"inputSchema": {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["query", "consensus", "list", "models", "feedback"],
"enum": ["query", "consensus", "list", "models", "limits", "feedback"],
"default": "query",
"description": "What to do"
},
@@ -268,7 +374,11 @@ impl LlmTool {
"messages": { "type": "array", "items": { "type": "object" }, "description": "query: the whole conversation as [{role, content}], instead of prompt" },
"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)" },
"family": { "type": "string", "description": "models: only this family, e.g. enso or kai" },
"fallback": { "type": "boolean", "description": "query: when the plan refuses the model, let its fallback answer instead (X-Hanzo-Fallback: allow)" },
"search": { "type": "string", "description": "models: words that must all appear in the id, name, owner or description" },
"class": { "type": "string", "enum": 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)" },
"request_id": { "type": "string", "description": "feedback: the completion id query returned (chatcmpl-...)" },
"signal": { "type": "string", "enum": SIGNALS, "description": "feedback: how the answer went" },
"rating": { "type": "number", "enum": [1, 2, 3], "description": "feedback: 1 to 3, with signal rating" },
@@ -293,10 +403,13 @@ impl LlmTool {
(None, Some(p)) => build_messages(args.system.as_deref(), p),
(None, None) => return invalid("prompt or messages required"),
};
let headers = match bounds(args) {
let mut headers = match bounds(args) {
Ok(h) => h,
Err(e) => return invalid(&e),
};
if args.fallback == Some(true) {
headers.push(("X-Hanzo-Fallback", "allow".to_string()));
}
let model = args.model.clone().filter(|m| !m.is_empty()).unwrap_or_else(|| DEFAULT_MODEL.to_string());
let mut body = json!({
@@ -321,17 +434,26 @@ impl LlmTool {
.map(|v| json!(v))
.or_else(|| resp.get("model").cloned())
.unwrap_or(json!(model));
// Who answered and who paid, as the gateway says on the response; a
// header it did not send is null (a free model sends only X-Hanzo-Served).
let said = |h: &str| headers.get(h).and_then(|v| v.to_str().ok()).map(|v| json!(v)).unwrap_or(Value::Null);
let data = json!({
"id": resp.get("id").cloned().unwrap_or(Value::Null),
"model": routed,
"content": extract_content(&resp),
"finish_reason": resp["choices"][0]["finish_reason"].clone(),
"usage": resp.get("usage").cloned().unwrap_or(Value::Null),
"served": said("x-hanzo-served"),
"paid_by": said("x-hanzo-paid-by"),
"usage_state": said("x-hanzo-usage"),
"usage_class": said("x-hanzo-usage-class"),
"fallback": said("x-hanzo-fallback"),
"fallback_reason": said("x-hanzo-usage-reason"),
"response": resp,
});
ToolResult::ok(envelope_ok("llm", "query", data))
}
Err(e) => ToolResult::ok(envelope_err("llm", "query", "UPSTREAM", e.to_string())),
Err(e) => refused("llm", "query", &e).unwrap_or_else(|| ToolResult::ok(envelope_err("llm", "query", "UPSTREAM", e.to_string()))),
})
}
@@ -382,11 +504,22 @@ impl LlmTool {
}
async fn models(&self, args: &LlmArgs) -> Result<ToolResult> {
if let Some(c) = norm(&args.class).filter(|c| !CLASSES.contains(&c.as_str())) {
return Ok(ToolResult::ok(envelope_err("llm", "models", "INVALID_ARGS", format!("class must be one of {}, not {c}", CLASSES.join(", ")))));
}
let body = match self.api.call(reqwest::Method::GET, "/v1/models", None, &[]).await {
Ok(a) => a.body,
Err(e) => return Ok(ToolResult::ok(envelope_err("llm", "models", "UPSTREAM", e.to_string()))),
Err(e) => return Ok(refused("llm", "models", &e).unwrap_or_else(|| ToolResult::ok(envelope_err("llm", "models", "UPSTREAM", e.to_string())))),
};
Ok(ToolResult::ok(envelope_ok("llm", "models", catalog(&body, args.family.as_deref()))))
let filter = Filter { search: args.search.clone(), class: args.class.clone(), family: args.family.clone(), capability: args.capability.clone() };
Ok(ToolResult::ok(envelope_ok("llm", "models", catalog(&body, &filter))))
}
async fn limits(&self) -> Result<ToolResult> {
Ok(match self.api.call(reqwest::Method::GET, "/v1/ai/limits", None, &[]).await {
Ok(a) => ToolResult::ok(envelope_ok("llm", "limits", standing(&a.body))),
Err(e) => refused("llm", "limits", &e).unwrap_or_else(|| ToolResult::ok(envelope_err("llm", "limits", "UPSTREAM", e.to_string()))),
})
}
async fn feedback(&self, args: &LlmArgs) -> Result<ToolResult> {
@@ -407,7 +540,7 @@ impl LlmTool {
}
Ok(match self.api.call(reqwest::Method::POST, "/v1/ai/feedback", Some(body), &[]).await {
Ok(a) => ToolResult::ok(envelope_ok("llm", "feedback", a.body.get("data").cloned().unwrap_or(a.body))),
Err(e) => ToolResult::ok(envelope_err("llm", "feedback", "UPSTREAM", e.to_string())),
Err(e) => refused("llm", "feedback", &e).unwrap_or_else(|| ToolResult::ok(envelope_err("llm", "feedback", "UPSTREAM", e.to_string()))),
})
}
}
@@ -424,7 +557,7 @@ impl MCPTool for LlmTool {
"llm"
}
fn description(&self) -> &str {
"Models through api.hanzo.ai: query (Enso routes), consensus, list, models (with prices), feedback"
"Models through api.hanzo.ai: query (Enso routes; who served and who paid), consensus, list, models (search, prices), limits, feedback"
}
fn parameters(&self) -> Value {
Self::schema()["inputSchema"].clone()
@@ -450,12 +583,13 @@ impl MCPTool for LlmTool {
"query" => self.query(&args).await,
"consensus" => self.consensus(&args).await,
"models" => self.models(&args).await,
"limits" => self.limits().await,
"feedback" => self.feedback(&args).await,
other => Ok(ToolResult::ok(envelope_err(
"llm",
other,
"UNKNOWN_ACTION",
"unknown action; valid: query, consensus, list, models, feedback",
"unknown action; valid: query, consensus, list, models, limits, feedback",
))),
}
}
@@ -470,7 +604,7 @@ mod tests {
let s = LlmTool::schema();
assert_eq!(s["name"], "llm");
let actions = s["inputSchema"]["properties"]["action"]["enum"].as_array().unwrap();
for a in ["query", "consensus", "list", "models", "feedback"] {
for a in ["query", "consensus", "list", "models", "limits", "feedback"] {
assert!(actions.iter().any(|v| v == a), "missing action {}", a);
}
}
+12
View File
@@ -78,6 +78,18 @@ pub(crate) fn envelope_err(tool: &str, action: &str, code: &str, message: impl I
json!({ "ok": false, "data": null, "error": { "code": code, "message": message.into() }, "meta": { "tool": tool, "action": action } })
}
/// A plan refusal as an MCP error result, or `None` when `e` is another error.
/// The text is `{"error": {...}}`, the same the TypeScript runtime answers; the
/// content is the envelope, for callers of the registry.
pub(crate) fn refused(tool: &str, action: &str, e: &anyhow::Error) -> Option<crate::ToolResult> {
let r = e.downcast_ref::<crate::hanzo_api::Refusal>()?;
Some(crate::ToolResult {
success: false,
content: json!({ "ok": false, "data": null, "error": r.error, "meta": { "tool": tool, "action": action } }),
error: Some(serde_json::to_string_pretty(&json!({ "error": r.error })).unwrap_or_default()),
})
}
/// Tool category for organization
#[derive(Debug, Clone, PartialEq)]
pub enum ToolCategory {
+157 -6
View File
@@ -101,10 +101,48 @@ async fn kai_decide_posts_the_decision_and_returns_it() {
assert_eq!(seen.body, json!({ "model": "kai", "state": { "message": "charged twice" }, "questions": questions }));
}
/// POST /v1/decisions model kai, org hanzo on the free plan, as it answered live.
fn free_plan_cap() -> Value {
json!({ "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" }
]
} })
}
#[tokio::test]
async fn kai_decide_carries_the_servers_refusal() {
async fn kai_decide_plan_refusal_is_an_error_naming_the_code_and_actions() {
let _env = own_env();
let (base, served) = serve(429, "", json!({ "error": { "message": "Free plan: today's Kai requests are used.", "code": "free_plan_cap" } })).await;
let (base, served) = serve(429, "Retry-After: 3204\r\n", free_plan_cap()).await;
point(&base);
let out = ToolRegistry::with_defaults()
.execute("kai_decide", json!({ "state": "s", "questions": { "q": { "type": "noul" } } }))
.await
.expect("executes");
served.await.expect("server");
assert!(!out.success, "a refusal is an MCP error result");
let error = json!({
"status": 429, "code": "free_plan_cap",
"message": "Free plan: today's Kai requests are used. Upgrade for more: https://hanzo.ai/pay",
"class": "ours", "resets_at": "2026-10-05T00:00:00Z",
"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" }
]
});
assert_eq!(out.content["error"], error);
let text: Value = serde_json::from_str(out.error.as_deref().expect("error text")).expect("json text");
assert_eq!(text, json!({ "error": error }));
}
#[tokio::test]
async fn a_402_without_a_plan_code_stays_the_servers_sentence() {
let _env = own_env();
let (base, served) = serve(429, "", json!({ "error": { "message": "The free pool is busy.", "code": "pool_busy" } })).await;
point(&base);
let out = ToolRegistry::with_defaults()
.execute("kai_decide", json!({ "state": "s", "questions": { "q": { "type": "noul" } } }))
@@ -112,7 +150,7 @@ async fn kai_decide_carries_the_servers_refusal() {
.expect("executes");
served.await.expect("server");
assert_eq!(out.content["ok"], false);
assert_eq!(out.content["error"]["message"], "429: Free plan: today's Kai requests are used.");
assert_eq!(out.content["error"]["message"], "429: The free pool is busy.");
}
#[tokio::test]
@@ -123,7 +161,7 @@ async fn llm_query_carries_enso_bounds_and_returns_the_model_that_served() {
"choices": [{ "index": 0, "finish_reason": "stop", "message": { "role": "assistant", "content": "Red" } }],
"usage": { "prompt_tokens": 110, "completion_tokens": 1, "total_tokens": 111 }
});
let (base, served) = serve(200, "X-Routed-Model: enso-free\r\n", completion).await;
let (base, served) = serve(200, "X-Routed-Model: enso-free\r\nX-Hanzo-Served: enso-free\r\n", completion).await;
point(&base);
let out = ToolRegistry::with_defaults()
.execute("llm", json!({ "action": "query", "model": "auto", "prompt": "One color.", "max_tokens": 20, "max_cost": 0.01, "max_latency_ms": 800 }))
@@ -134,7 +172,10 @@ async fn llm_query_carries_enso_bounds_and_returns_the_model_that_served() {
assert_eq!(data["id"], "chatcmpl-27d2ce46");
assert_eq!(data["model"], "enso-free");
assert_eq!(data["content"], "Red");
assert_eq!(data["served"], "enso-free");
assert_eq!(data["paid_by"], Value::Null);
let seen = served.await.expect("server");
assert!(!seen.headers.contains_key("x-hanzo-fallback"));
assert_eq!(seen.start, "POST /v1/chat/completions HTTP/1.1");
assert_eq!(seen.headers.get("x-max-cost").map(String::as_str), Some("0.01"));
assert_eq!(seen.headers.get("x-max-latency-ms").map(String::as_str), Some("800"));
@@ -180,10 +221,120 @@ async fn llm_models_lists_the_catalogs_own_prices_by_family() {
let seen = served.await.expect("server");
assert_eq!(seen.start, "GET /v1/models HTTP/1.1");
assert_eq!(out.content["data"], json!({ "count": 1, "models": [
{ "id": "kai", "family": "kai", "class": "ours", "outputs": ["decision"], "context_window": null, "input_per_million": 0.021, "output_per_million": 0 }
{ "id": "kai", "family": "kai", "class": "ours", "outputs": ["decision"], "context_window": null, "supports": [], "input_per_million": 0.021, "output_per_million": 0, "variable": false }
] }));
}
#[tokio::test]
async fn llm_query_says_who_served_and_who_paid_and_opts_into_the_fallback() {
let _env = own_env();
let completion = json!({ "id": "chatcmpl-9", "model": "enso", "choices": [{ "message": { "content": "ok" } }] });
let (base, served) = serve(200, "X-Hanzo-Served: enso\r\nX-Hanzo-Paid-By: plan\r\nX-Hanzo-Usage: limited\r\nX-Hanzo-Usage-Class: premium\r\nX-Hanzo-Fallback: enso\r\nX-Hanzo-Usage-Reason: model_cap\r\n", completion).await;
point(&base);
let out = ToolRegistry::with_defaults()
.execute("llm", json!({ "model": "anthropic/claude-opus-4.1", "prompt": "hi", "fallback": true }))
.await
.expect("executes");
let seen = served.await.expect("server");
assert_eq!(seen.headers.get("x-hanzo-fallback").map(String::as_str), Some("allow"));
assert_eq!(seen.body["model"], "anthropic/claude-opus-4.1");
let d = &out.content["data"];
assert_eq!((d["served"].as_str(), d["paid_by"].as_str()), (Some("enso"), Some("plan")));
assert_eq!((d["usage_state"].as_str(), d["usage_class"].as_str()), (Some("limited"), Some("premium")));
assert_eq!((d["fallback"].as_str(), d["fallback_reason"].as_str()), (Some("enso"), Some("model_cap")));
}
#[tokio::test]
async fn llm_query_model_cap_offers_the_switch() {
let _env = own_env();
let (base, served) = serve(402, "", json!({ "error": {
"message": "This model has used its share of your plan for now. Try Enso, continue with credits, or upgrade: https://hanzo.ai/pay",
"type": "billing_error", "code": "model_cap", "class": "premium", "model": "anthropic/claude-opus-4.1", "fallback": "enso",
"upgrade_url": "https://hanzo.ai/pay/cart?plan=max-20x",
"actions": [
{ "kind": "upgrade", "label": "Upgrade your plan", "url": "https://hanzo.ai/pay/cart?plan=max-20x", "plan": "max-20x" },
{ "kind": "switch", "label": "Try Enso", "model": "enso" },
{ "kind": "credits", "label": "Continue with credits", "url": "/v1/ai/limits" }
]
} })).await;
point(&base);
let out = ToolRegistry::with_defaults().execute("llm", json!({ "model": "anthropic/claude-opus-4.1", "prompt": "hi" })).await.expect("executes");
served.await.expect("server");
assert!(!out.success);
let e = &out.content["error"];
assert_eq!((e["status"].as_u64(), e["code"].as_str(), e["fallback"].as_str()), (Some(402), Some("model_cap"), Some("enso")));
assert_eq!(e["actions"][1], json!({ "kind": "switch", "label": "Try Enso", "model": "enso" }));
assert!(e.get("type").is_none() && e.get("upgrade_url").is_none());
}
fn catalog() -> Value {
json!({ "object": "list", "data": [
{ "id": "kai", "family": "kai", "class": "ours", "outputs": ["decision"], "pricing": { "input_per_million": 0.021, "output_per_million": 0 } },
{ "id": "zen5", "family": "zen", "class": "free", "outputs": ["text"], "supports_vision": true, "pricing": { "input_per_million": 0, "output_per_million": 0 } },
{ "id": "zen-embedding", "owned_by": "zenlm", "family": "zen", "class": "ours", "outputs": ["embeddings"], "pricing": { "input_per_million": 0.01, "output_per_million": 0.01 } },
{ "id": "anthropic/claude-sonnet-4.5", "owned_by": "anthropic", "name": "Claude Sonnet 4.5", "class": "premium", "inputs": ["text", "image", "file"], "outputs": ["text"], "supports_vision": true, "supports_tools": true, "supports_reasoning": true, "pricing": { "input_per_million": 3.6, "output_per_million": 18 } },
{ "id": "typesafe/jev-router", "name": "Jev Router", "class": "premium", "inputs": ["audio", "file", "image", "text", "video"], "outputs": ["text"], "supports_tools": true, "pricing": { "input_per_million": 180, "output_per_million": 720, "variable": true } }
] })
}
async fn models(args: Value) -> Vec<String> {
let (base, served) = serve(200, "", catalog()).await;
point(&base);
let out = ToolRegistry::with_defaults().execute("llm", args).await.expect("executes");
served.await.expect("server");
out.content["data"]["models"].as_array().expect("models").iter().map(|m| m["id"].as_str().unwrap().to_string()).collect()
}
#[tokio::test]
async fn llm_models_searches_by_text_class_and_capability() {
let _env = own_env();
assert_eq!(models(json!({ "action": "models", "class": "ours" })).await, ["kai", "zen-embedding"]);
assert_eq!(models(json!({ "action": "models", "search": "Sonnet" })).await, ["anthropic/claude-sonnet-4.5"]);
assert_eq!(models(json!({ "action": "models", "search": "zenlm" })).await, ["zen-embedding"]);
assert_eq!(models(json!({ "action": "models", "capability": "tools" })).await, ["anthropic/claude-sonnet-4.5", "typesafe/jev-router"]);
assert_eq!(models(json!({ "action": "models", "capability": "embeddings" })).await, ["zen-embedding"]);
assert_eq!(models(json!({ "action": "models", "class": "free", "family": "zen", "capability": "vision" })).await, ["zen5"]);
let (base, served) = serve(200, "", catalog()).await;
point(&base);
let out = ToolRegistry::with_defaults().execute("llm", json!({ "action": "models", "search": "router" })).await.unwrap();
served.await.expect("server");
assert_eq!(out.content["data"]["models"][0]["variable"], true);
assert_eq!(out.content["data"]["models"][0]["supports"], json!(["tools"]));
let bad = ToolRegistry::with_defaults().execute("llm", json!({ "action": "models", "class": "gold" })).await.unwrap();
assert!(bad.content["error"]["message"].as_str().unwrap().contains("class must be one of premium, ours, free"));
}
#[tokio::test]
async fn llm_limits_reads_shares_states_resets_and_actions_and_no_figure() {
let _env = own_env();
let (base, served) = serve(200, "", json!({
"plan": "max-5x", "period_start": "2026-10-01T00:00:00Z", "period_end": "2026-11-01T00:00:00Z", "state": "near",
"classes": {
"premium": { "percent": 85, "state": "near", "paying": "plan", "resets_at": "2026-11-01T00:00:00Z", "window": { "percent": 40, "state": "ok", "resets_at": "2026-10-05T03:00:00Z" }, "used_cents": 4250, "cap_cents": 5000 }
},
"session": { "percent": 20, "state": "ok", "resets_at": "2026-10-05T03:00:00Z", "used": 9, "limit": 45 },
"paused": [{ "model": "anthropic/claude-opus-4.1", "fallback": "enso", "resets_at": "2026-11-01T00:00:00Z", "share_cents": 1500 }],
"actions": [{ "kind": "upgrade", "label": "Upgrade to Max 20x", "url": "https://hanzo.ai/pay/cart?plan=max-20x", "plan": "max-20x", "price_cents": 20000 }],
"upgrade": "max-20x", "credits_after_allowance": true, "allowance_cents": 5000, "balance_cents": 1234
})).await;
point(&base);
let out = ToolRegistry::with_defaults().execute("llm", json!({ "action": "limits" })).await.expect("executes");
let seen = served.await.expect("server");
assert_eq!(seen.start, "GET /v1/ai/limits HTTP/1.1");
assert_eq!(out.content["data"], json!({
"plan": "max-5x", "state": "near", "period_start": "2026-10-01T00:00:00Z", "period_end": "2026-11-01T00:00:00Z",
"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" } } },
"session": { "percent": 20, "state": "ok", "resets_at": "2026-10-05T03:00:00Z" },
"paused": [{ "model": "anthropic/claude-opus-4.1", "fallback": "enso", "resets_at": "2026-11-01T00:00:00Z" }],
"actions": [{ "kind": "upgrade", "label": "Upgrade to Max 20x", "url": "https://hanzo.ai/pay/cart?plan=max-20x", "plan": "max-20x" }],
"upgrade": "max-20x", "credits_after_allowance": true
}));
let text = out.content.to_string();
for figure in ["cents", "\"used\"", "\"limit\"", "balance", "4250", "1234", "5000"] {
assert!(!text.contains(figure), "{figure} reached the result: {text}");
}
}
#[tokio::test]
async fn llm_feedback_posts_the_signal_and_returns_what_was_recorded() {
let _env = own_env();
@@ -218,5 +369,5 @@ async fn both_tools_are_advertised() {
let kai = defs.iter().find(|d| d["name"] == "kai_decide").expect("kai_decide in tools/list");
assert_eq!(kai["inputSchema"]["required"], json!(["state", "questions"]));
let llm = defs.iter().find(|d| d["name"] == "llm").expect("llm in tools/list");
assert_eq!(llm["inputSchema"]["properties"]["action"]["enum"], json!(["query", "consensus", "list", "models", "feedback"]));
assert_eq!(llm["inputSchema"]["properties"]["action"]["enum"], json!(["query", "consensus", "list", "models", "limits", "feedback"]));
}