mirror of
https://github.com/hanzoai/mcp.git
synced 2026-10-15 12:30:12 +00:00
feat(rust): llm limits, catalog search, served and paid-by, plan refusals
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"]));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user