MiniLLMLib (Rust)
A minimalist, async-first Rust library for talking to Large Language Models over HTTP, with one consistent API across every provider.
The headline idea: ChatNode::root(...).chat(...) is identical no matter what
is behind it. A Provider owns the entire wire dialect (endpoint, auth,
request body, response and stream envelope, cost accounting). Your code only ever
deals in normalized types, so switching from OpenRouter to OpenAI, to a native
Anthropic key, to a Claude subscription, or to your own self-hosted server is a
one-line change.
What's here
- Conversation trees. A conversation is a tree of
ChatNodehandles. Linear chats, branching, and prebuilt history all use the same structure. - Multiple providers. OpenRouter, OpenAI, native Anthropic (
/v1/messages), a generic OpenAI-compatible provider for self-hosted servers, and your own hand-writtenimpl Providerfor any other wire. - Streaming over SSE, with an idle-timeout that won't kill a long live generation but fails loudly on a dead connection.
- Honest cost tracking. Per-provider usage and cost, with disjoint
cached/uncached/cache-write token buckets and a
CostResolution(Resolved/Unpriced/Unknown) that never reports a fake$0. - Prompt caching, marked on the tree and enforced per-provider.
- Claude subscription auth: use your Pro/Max plan instead of an API key.
- JSON repair for malformed model output.
Two layers of documentation
| Layer | What | Where |
|---|---|---|
| This guide | Tutorials, patterns, worked examples | the pages on the left |
| API reference | Every public type, method, and signature | docs.rs/minillmlib |
The guide teaches you how to use the library; the API reference (auto-generated from the source by docs.rs) is the exhaustive signature lookup. Start with Quickstart.
Quickstart
Install
# Cargo.toml
[dependencies]
minillmlib = "0.5"
tokio = { version = "1", features = ["full"] }
One call
use minillmlib::{ChatNode, GeneratorInfo}; #[tokio::main] async fn main() -> minillmlib::Result<()> { // Pick a provider. OpenRouter reads OPENROUTER_API_KEY from the environment. let generator = GeneratorInfo::openrouter("google/gemini-2.5-flash-lite"); // A conversation is a tree; `root` is the system prompt. let root = ChatNode::root("You are a helpful assistant. Be brief."); // `chat` = add a user message + get the assistant reply (returns the new node). let answer = root.chat("Say hello in five words.", &generator).await?; println!("{}", answer.message.text().unwrap_or("")); Ok(()) }
That is the 80% case. Swapping the provider is a one-line change and nothing else moves:
#![allow(unused)] fn main() { use minillmlib::GeneratorInfo; GeneratorInfo::openrouter("google/gemini-2.5-flash-lite"); // OPENROUTER_API_KEY GeneratorInfo::openai("gpt-4o-mini"); // OPENAI_API_KEY GeneratorInfo::anthropic("claude-haiku-4-5"); // ANTHROPIC_API_KEY, native /v1/messages GeneratorInfo::claude_subscription("claude-haiku-4-5"); // your Pro/Max plan, no API key GeneratorInfo::custom("my", "http://localhost:8000/v1", "m"); // your own OpenAI-compatible server }
See Providers for what each does, and Custom Providers for connecting your own server.
A multi-turn conversation
Each chat returns the assistant node; chain from it to continue the thread.
use minillmlib::{ChatNode, GeneratorInfo}; #[tokio::main] async fn main() -> minillmlib::Result<()> { let gen = GeneratorInfo::openrouter("google/gemini-2.5-flash-lite"); let root = ChatNode::root("You are a terse assistant."); let a1 = root.chat("What's the capital of France?", &gen).await?; let a2 = a1.chat("And its population, roughly?", &gen).await?; println!("{}", a2.message.text().unwrap_or("")); // a2 knows its whole history: a2.thread() is the full root-to-leaf message list. Ok(()) }
Errors
Every fallible call returns minillmlib::Result<T>, an alias for
Result<T, MiniLLMError>. The library fails loudly: an auth/validation error,
a malformed response, or an exhausted retry surface as a typed MiniLLMError,
never a silent empty success.
Conversation Trees
A conversation is a tree of ChatNode handles. Each node holds one Message;
children are alternate continuations. A linear chat is just a tree with one
branch. Holding any node keeps its whole ancestor chain (and the tree) alive.
Building a thread
add_user / add_assistant each return the new node, so you chain them:
#![allow(unused)] fn main() { use minillmlib::ChatNode; let root = ChatNode::root("You are a terse assistant."); let leaf = root .add_user("What's the capital of France?") .add_assistant("Paris.") .add_user("And Germany?") .add_assistant("Berlin.") .add_user("And Italy?"); // the turn we want answered }
leaf.thread() is the full [system, user, assistant, user, assistant, user]
message list from root to leaf.
Completing from any node
node.complete(generator, params) uses node's root-to-node path as the prompt
and appends the reply as a child of node, returning the new assistant node.
#![allow(unused)] fn main() { use minillmlib::{ChatNode, GeneratorInfo}; async fn run(leaf: ChatNode, gen: GeneratorInfo) -> minillmlib::Result<()> { let answer = leaf.complete(&gen, None).await?; // None = default per-request params println!("{}", answer.message.text().unwrap_or("")); Ok(()) } }
You can complete from any node, not just the leaf, to branch off it. The whole root-to-that-node path is the context.
Prebuilt history from a Vec<Message>
When you already have a message list, from_messages builds the linear chain and
hands back (root, leaf). Complete from the leaf.
#![allow(unused)] fn main() { use minillmlib::{ChatNode, GeneratorInfo, Message}; async fn run(gen: GeneratorInfo) -> minillmlib::Result<()> { let history = vec![ Message::system("You are a terse assistant."), Message::user("What's the capital of France?"), Message::assistant("Paris."), Message::user("And Germany?"), Message::assistant("Berlin."), Message::user("And Italy?"), ]; let (_root, leaf) = ChatNode::from_messages(&history)?; let answer = leaf.complete(&gen, None).await?; Ok(()) } }
Ownership: keep a handle
The tree lives in a shared arena that stays alive as long as you hold any
handle into it. from_messages returns both root and leaf precisely so you
don't accidentally drop the only handle. When you chain add_user/add_assistant,
holding the final node is enough (it keeps its whole ancestor chain). Drop every
handle and the thread is freed.
Saving and loading threads
#![allow(unused)] fn main() { use minillmlib::ChatNode; fn run(leaf: ChatNode) -> minillmlib::Result<()> { leaf.save_thread("conversation.json")?; let (root, leaf) = ChatNode::from_thread_file("conversation.json")?; Ok(()) } }
Providers
A GeneratorInfo bundles a model, a base URL, an auth strategy, and a
Provider (the wire dialect). The provider owns everything that differs between
APIs; your calling code never changes. The crate ships these presets:
| Preset | Wire | Auth (env var) | Cost |
|---|---|---|---|
GeneratorInfo::openrouter(model) | OpenAI /chat/completions | OPENROUTER_API_KEY | native USD, with a /generation fallback |
GeneratorInfo::openai(model) | OpenAI /chat/completions | OPENAI_API_KEY | token-only (set a TokenPrice) |
GeneratorInfo::anthropic(model) | native /v1/messages, content[] | ANTHROPIC_API_KEY (x-api-key) | token-only (set a TokenPrice) |
GeneratorInfo::claude_subscription(model) | native /v1/messages | Pro/Max OAuth token | token-only ESTIMATE |
GeneratorInfo::custom(name, base_url, model) | OpenAI-compatible (default) | none unless you add one | token-only |
Auth
Auth is a strategy on the generator, mapped to concrete headers by the provider (so the same Anthropic provider serves both an API key and a subscription token):
#![allow(unused)] fn main() { use minillmlib::GeneratorInfo; let g = GeneratorInfo::openai("gpt-4o-mini"); g.clone().with_api_key("sk-..."); // provider picks the header (Bearer / x-api-key) g.clone().with_api_key_from_env("MY_KEY"); // no-op if the var is unset g.clone().with_bearer_token("token"); // always Authorization: Bearer g.clone().with_header("X-Tenant", "acme"); // any extra header }
Cost for token-only providers
OpenAI and Anthropic return token counts but no dollar amount. Attach a
TokenPrice (USD per million tokens, the unit every price sheet quotes) to
get a resolved cost; otherwise tracking reports Unpriced (never a fake $0):
#![allow(unused)] fn main() { use minillmlib::{GeneratorInfo, TokenPrice}; let gen = GeneratorInfo::anthropic("claude-haiku-4-5") .with_token_price(TokenPrice::new(1.0, 5.0)); // $1/Mtok in, $5/Mtok out }
See Cost Tracking for the full picture.
OpenRouter routing
OpenRouter-specific routing (provider order, sort, data-collection) is attached
honestly through the extra escape hatch rather than masquerading as a universal
parameter:
#![allow(unused)] fn main() { use minillmlib::{CompletionParameters, ProviderSettings}; let routing = ProviderSettings::new() .sort_by_throughput() .deny_data_collection(); let params = CompletionParameters::new() .with_openrouter_routing(routing); }
Non-OpenRouter providers simply ignore it.
Completion Parameters
Two layers of parameters:
CompletionParameters: normalized generation intent (temperature, max tokens, stop, response format, ...). NOT a wire shape: each provider'sbuild_requestmaps it to its own request body, so the same params drive any provider identically.NodeCompletionParameters: per-request behavior around the call (system prompt override, JSON repair, retry, cost tracking, caching, the wrappedCompletionParameters).
You pass NodeCompletionParameters to complete; None means defaults.
CompletionParameters
#![allow(unused)] fn main() { use minillmlib::CompletionParameters; let params = CompletionParameters::new() .with_max_tokens(512) .with_temperature(0.7) .with_stop(vec!["END".to_string()]); }
| Field | Meaning |
|---|---|
max_tokens | Provider emits its own key (max_completion_tokens, max_tokens, Anthropic's required max_tokens) |
temperature, top_p, top_k | Sampling |
frequency_penalty, presence_penalty, repetition_penalty | Penalties |
stop | Stop sequences (Anthropic stop_sequences) |
seed | Reproducibility |
response_format | Force JSON output (with_json_response()) |
reasoning | Extended-thinking effort/budget |
tools, tool_choice, parallel_tool_calls | Normalized tool calling; the provider emits its wire shape (see Tool Calling) |
extra | Provider-specific keys (the honest escape hatch, e.g. OpenRouter routing) |
CompletionParameters is also a serde type: camelCase keys, every field
optional (missing ones take the defaults above), unknown keys ignored. A flat
JSON settings object ({"maxTokens": 1024, "temperature": 0.2}) deserializes
directly, which is handy when parameters arrive as user-facing config.
NodeCompletionParameters
#![allow(unused)] fn main() { use minillmlib::{CompletionParameters, NodeCompletionParameters}; let params = NodeCompletionParameters::new() .with_params(CompletionParameters::new().with_max_tokens(200)) .with_system_prompt("You are concise.") // prepend if the thread has no system message .expecting_json() // parse + repair the response as JSON .with_force_prepend("Answer: ") // make the model continue from this prefix .with_cost_tracking(true); // request usage and fire the cost callback }
| Builder | Meaning |
|---|---|
with_params(..) | The wrapped CompletionParameters |
with_system_prompt(..) | Prepend a system message if absent |
with_format_kwargs(..) / with_format_kwarg(k, v) | Fill {placeholder}s thread-wide at call time |
with_parse_json(true) / expecting_json() | Repair the response as JSON |
with_force_prepend(..) | Prime the assistant turn so the model continues it |
with_cache(true) | Auto-mark the whole prefix for caching (see Caching) |
with_cost_tracking(true) | Request and report usage/cost |
with_token_price(..) | Per-request price override |
retry, exp_back_off, back_off_time, max_back_off | Retry policy |
crash_on_refusal, crash_on_empty_response | Reject empty / no-JSON responses |
timeout_secs | Total deadline (non-streaming) or idle timeout (streaming) |
Tool Calling
Tools are normalized intent, like every other parameter: you declare
ToolDefinitions and a ToolChoice once, and each provider emits its own wire
shape. The same code drives OpenRouter, OpenAI, any OpenAI-compatible server,
and native Anthropic.
| Normalized | OpenAI-wire (OpenRouter, OpenAI, compatibles) | Anthropic /v1/messages |
|---|---|---|
ToolDefinition { name, description, parameters, strict } | {"type":"function","function":{name, description, parameters, strict}} | {name, description, input_schema, strict} |
ToolChoice::Auto / None / Required / Tool(name) | "auto" / "none" / "required" / {"type":"function","function":{"name"}} | {"type":"auto"} / {"type":"none"} / {"type":"any"} / {"type":"tool","name"} |
parallel_tool_calls: false | top-level "parallel_tool_calls": false | tool_choice.disable_parallel_tool_use: true |
assistant ToolCalls | message.tool_calls[] (arguments as a JSON string) | tool_use content blocks (input as an object) |
Message::tool(call_id, content) | {"role":"tool","tool_call_id","content"} | a user turn with tool_result blocks |
The loop
#![allow(unused)] fn main() { use minillmlib::{ ChatNode, CompletionParameters, GeneratorInfo, NodeCompletionParameters, ToolChoice, ToolDefinition, }; async fn run() -> minillmlib::Result<()> { let gen = GeneratorInfo::openrouter("anthropic/claude-sonnet-4.5"); let params = NodeCompletionParameters::new().with_params( CompletionParameters::new() .with_tool(ToolDefinition::new( "get_weather", "Get the current weather for a city", serde_json::json!({ "type": "object", "properties": { "city": { "type": "string" } }, "required": ["city"], }), )) .with_tool_choice(ToolChoice::Auto), ); let node = ChatNode::root("You are helpful.") .add_user("What's the weather in Paris?") .complete(&gen, Some(¶ms)) .await?; // The model called a tool: run it and answer each call, then complete again. if let Some(calls) = node.tool_calls() { let mut current = node.clone(); for call in &calls { let args = call.arguments_json()?; // parsed arguments, fails loudly let result = format!("15 degrees in {}", args["city"]); current = current.add_tool_result(&call.id, result); } let answer = current.complete(&gen, Some(¶ms)).await?; println!("{}", answer.text().unwrap_or("")); } Ok(()) } }
Notes:
- Keep the same
toolsin the follow-up request. Providers require the tool definitions to still be present when you send back the results. - Parallel calls: the model may return several
ToolCalls in one turn; add oneadd_tool_resultper call (in any order). The Anthropic provider packs consecutive results into the singleuserturn its wire requires. Forbid parallelism with.with_parallel_tool_calls(false). - Arguments are raw JSON text (
ToolCall::arguments), exactly as the model produced them;arguments_json()parses them and fails loudly on invalid JSON instead of silently repairing. - Forcing a call:
ToolChoice::Required(any tool) orToolChoice::Tool("get_weather".into())(that one). - Strict schemas:
ToolDefinition::with_strict(true)asks the provider to guarantee the arguments match your schema (OpenAI structured outputs, Anthropic strict tool use). - Streaming works too: tool-call fragments are accumulated across chunks and
the final
CompletionResponse::tool_calls(and the node) carry the assembled calls.
Streaming a tool call as it is generated
You don't have to wait for the model to finish a call before acting on it. The
streaming chunks expose typed ToolCallDelta fragments with the same timing on
both wires: the first fragment carries the call's name (and id), then each
later fragment carries a piece of the raw JSON argument text, in order. That
lets you start the tool the moment the model names it and pipe the argument
bytes in while the model is still generating them.
#![allow(unused)] fn main() { use minillmlib::{ ChatNode, CompletionParameters, GeneratorInfo, NodeCompletionParameters, ToolChoice, ToolDefinition, }; async fn run() -> minillmlib::Result<()> { let gen = GeneratorInfo::openrouter("anthropic/claude-sonnet-4.5"); let params = NodeCompletionParameters::new().with_params( CompletionParameters::new() .with_tool( ToolDefinition::new( "run_python", "Execute Python code", serde_json::json!({ "type": "object", "properties": { "code": { "type": "string" } }, "required": ["code"], }), ) .with_strict(true), ) .with_tool_choice(ToolChoice::Tool("run_python".into())), ); let root = ChatNode::root("You are helpful."); let user = root.add_user("Compute the 100th Fibonacci number."); let mut stream = user.complete_streaming(&gen, Some(¶ms)).await?; let mut tool_started = false; while let Some(chunk) = stream.next_chunk().await { let chunk = chunk?; if let Some(deltas) = &chunk.tool_calls { for delta in deltas { // First fragment carries the name: start the tool NOW // (e.g. spawn the interpreter process here). if let Some(name) = &delta.name { println!(">> model is calling {name}, starting process"); tool_started = true; } // Later fragments: raw JSON argument text, in order. if let Some(frag) = &delta.arguments_fragment { if tool_started { // CAVEAT: this is escaped JSON source, e.g. // {"code": "print(\"hi\")... (see the note below). print!("{frag}"); } } } } } // The stream assembled the complete calls in parallel: append the assistant // node and finish the normal loop (add_tool_result + complete again). let response = stream.collect().await?; let node = user.append_response(&response); if let Some(calls) = node.tool_calls() { let result = node.add_tool_result(&calls[0].id, "354224848179261915075"); let answer = result.complete(&gen, Some(¶ms)).await?; println!("{}", answer.text().unwrap_or("")); } Ok(()) } }
Notes:
- The fragments are JSON source text, not your payload. For a tool whose
input is one string field (like
codeabove), the bytes arrive escaped and wrapped in the object syntax ({"code": "print(\"hi...). Put anArgumentStreambetween the fragments and the tool to stream the DECODED content instead (see the next section). - Parallel calls:
delta.indexdisambiguates concurrent calls; key your spawned tools by it. Forcing a single call withToolChoice::Tool(..)(and.with_parallel_tool_calls(false)) sidesteps this. - Key order: models may emit argument keys in any order, so with several
fields your payload field can arrive last.
strict: trueplus a one-property schema keeps the stream predictable.
For the complete pattern (a multi-turn agent loop mixing a streaming tool and a
buffered tool, forwarding all prose live), see
examples/agent_loop.rs:
cargo run --example agent_loop. The key mental model: a tool call always
ends the model's turn; "the model continues after the tool" is always a new
API request that your loop makes after add_tool_result, and the consumer of
your stream never sees the seams.
Streaming decoded arguments (ArgumentStream)
ArgumentStream decodes a call's raw argument fragments field by field, live.
Every field is the same kind of object: take a FieldHandle for it and choose
PER FIELD how to consume it:
handle.wait().await: the complete parsed value, once the field ends.handle.delta().await: the field's DECODED text chunk by chunk as the model generates it (\na real newline,\"a quote,\uXXXXthe character): type code into an editor in real time, pipe into a process's stdin.
Fields nobody took a handle for are parsed into args.fields() as they
complete, so a fully non-streaming consumer still gets everything extracted at
the end. Fragments may split at any position (mid-escape included); the output
never changes.
#![allow(unused)] fn main() { use minillmlib::ArgumentStream; async fn run() -> minillmlib::Result<()> { let mut args = ArgumentStream::lenient(); let path = args.field("path"); // consume as a whole value let mut content = args.field("content"); // consume as a live stream // The tool runs concurrently with the wire: let tool = tokio::spawn(async move { let path = path.wait().await?; // complete, parsed let mut session = open_editor(path.as_str().unwrap()); while let Some(text) = content.delta().await { // decoded, live session.type_text(&text); } Ok::<_, minillmlib::MiniLLMError>(session.close()) }); // The driver feeds the fragments from the streaming loop: let provider_fragments: Vec<String> = vec![]; for fragment in provider_fragments { args.feed(&fragment)?; } args.finish()?; // resolves holdbacks, closes every handle tool.await.unwrap()?; Ok(()) } fn open_editor(_p: &str) -> Session { Session } struct Session; impl Session { fn type_text(&mut self, _t: &str) {} fn close(self) {} } }
Two modes:
ArgumentStream::strict()(default choice): the arguments must be well-formed JSON; a bad escape, unescaped control character, or unterminated string/object fails loudly with the raw text in the error.ArgumentStream::lenient(): for models sloppy at escaping, applied to EVERY top-level string value. The rule is deterministic because the legitimate ways a string can end are known: an unescaped"closes the string only when followed by, "key":(the next field's declaration; whitespace optional everywhere, and the key is a full JSON string, spaces included) or by}at the true end of the call (the provider signals that end explicitly). Every other"is literal content, a raw newline is itself,\before a non-escape character is a literal backslash, and a model that just stops (forgot the closing"or}) still delivers the full content, never silently dropped. Onfinish(), lenient mode also runs the raw arguments through the crate's JSON repair and fills anything the incremental parse missed intofields(). The one documented misfire: content that literally contains", "somekey":reads as a field boundary; that ambiguity is unresolvable on the wire. Numbers, booleans, and nested objects must be well-formed in both modes (they have no end anchor).
Any number of fields can be streamed (a patch(old_code, new_code) tool works
fine); a handle whose field never arrives resolves as a loud error at
finish(). examples/agent_loop.rs uses it for its streaming tool.
For the fully non-streaming path, the assembled call offers the same
robustness: ToolCall::arguments_json() parses strictly, and
ToolCall::arguments_json_repaired() runs the arguments through the crate's
JSON repair first (trailing commas, unclosed braces, single quotes).
Custom wire shapes
An OpenAI-envelope server whose tool shape deviates only needs to override the
two tool hooks on Provider (openai_tools_value, openai_tool_choice_value);
a fully different wire translates params.tools / params.tool_choice /
message.tool_calls itself in its build_request. See
Custom Providers.
Cost Tracking
The library tracks usage and cost per request, and is honest about when a cost is actually known.
Token buckets
Input tokens are split into three disjoint, additive buckets so caching is priced correctly across every provider's differing wire conventions:
uncached_input_tokens: full-price prompt tokens,cache_read_tokens: served from a warm cache (cheap),cache_write_tokens: written to the cache this request (a premium).
Total input is the sum of the three; cost is a clean weighted sum, no subtraction.
Resolution: never a fake $0
Every reported CostInfo carries a CostResolution:
| Resolution | Meaning |
|---|---|
Resolved | The USD cost is authoritative (native, or tokens × a configured TokenPrice) |
Unpriced | Tokens are real, but no native cost and no TokenPrice was set. cost is 0.0 but must NOT be treated as a free request. Set a TokenPrice to resolve it. |
Unknown | Cost could not be determined at all (no usage, and any out-of-band query failed) |
Check resolution before trusting cost.
The reply and the bill together: complete_costed
The simplest tracked shape, and the right default when the caller itself acts
on the cost (settles a budget hold, gates the next call, logs it right there):
the CostInfo comes back WITH the result, no callback to register.
#![allow(unused)] fn main() { use minillmlib::{ChatNode, GeneratorInfo}; async fn run() { let generator = GeneratorInfo::openrouter("google/gemini-2.5-flash-lite"); let root = ChatNode::root("You are helpful."); let (result, cost) = root.add_user("Hi").complete_costed(&generator, None).await; let reply = result.expect("completion"); if let Some(cost) = cost { println!("that cost ${:.6} ({:?})", cost.cost, cost.resolution); } } }
Same accounting as every other tracked path (usage from the response, the provider's out-of-band resolution as backstop, never a fake $0). An errored completion carries no cost info: the request failed before a billable response existed.
When many call sites should feed one central sink instead, use the callback shapes below; streaming always goes through them, since a stream's cost resolves only after it ends.
A callback per completion
#![allow(unused)] fn main() { use minillmlib::{ChatNode, GeneratorInfo, NodeCompletionParameters, CompletionParameters, CostInfo}; use std::sync::{Arc, Mutex}; async fn run() -> minillmlib::Result<()> { let gen = GeneratorInfo::openrouter("google/gemini-2.5-flash-lite"); let total = Arc::new(Mutex::new(0.0)); let sink = total.clone(); let params = NodeCompletionParameters::new() .with_params(CompletionParameters::new().with_max_tokens(200)) .with_cost_tracking(true) .with_cost_callback(move |info: CostInfo| { // info.cost, .prompt_tokens, .completion_tokens, // .cache_read_tokens, .cache_write_tokens, .resolution *sink.lock().unwrap() += info.cost; }); let root = ChatNode::root("You are helpful."); root.add_user("Hi").complete(&gen, Some(¶ms)).await?; println!("total spent: {}", *total.lock().unwrap()); Ok(()) } }
Enforced tracking via CompletionContext
When you want cost reporting to be structurally guaranteed (not opt-in per call),
wrap the generator in a CompletionContext and use complete_tracked. It always
reports cost through the context's async callback, and on a cancelled or
usage-less stream it resolves out-of-band (e.g. OpenRouter's /generation query)
or reports Unknown, rather than silently booking $0.
#![allow(unused)] fn main() { use minillmlib::{CompletionContext, CostInfo, AsyncCostCallback, CompletionMeta, GeneratorInfo, ChatNode}; use std::sync::Arc; async fn run() -> minillmlib::Result<()> { let generator = GeneratorInfo::openrouter("m"); let callback: AsyncCostCallback = Arc::new(|cost: CostInfo, _meta: CompletionMeta| { Box::pin(async move { // persist `cost` to your DB / metering here let _ = cost; }) }); let ctx = CompletionContext::new(generator, serde_json::json!({}), callback, "https://app", "App"); let root = ChatNode::root("You are helpful."); let _answer = root.add_user("Hi").complete_tracked(&ctx, None).await?; Ok(()) } }
For streaming, complete_streaming_tracked returns a TrackedStream that settles
cost when it finishes or is cancelled (use cancel().await for a reliable
settle; a plain drop is best-effort).
Cost Estimation
Cost tracking tells you what a call did cost. Estimation tells you what it
will cost, before you send it, so you can decide whether to allow it (reserve
budget, gate a request, refuse an over-priced job). Enable the estimate
feature:
minillmlib = { version = "0.5", features = ["estimate"] }
One call on the generator
The GeneratorInfo you already send completions with answers directly:
#![allow(unused)] fn main() { use minillmlib::{ChatNode, CompletionParameters, GeneratorInfo}; let generator = GeneratorInfo::openrouter("anthropic/claude-haiku-4.5"); let params = CompletionParameters::new().with_max_tokens(1024); let root = ChatNode::root("You are terse."); let prompt = root.add_user("Name three primary colours."); let usd = generator.estimate_cost_usd(&prompt.thread(), ¶ms).await?; println!("this call will cost at most ${usd:.6}"); }
If you want the raw rates instead (to price several prompts, or to combine with
cost tracking's token buckets), use
generator.model_rates(), which returns the per-million-token prices and the
model's limits.
Where the prices come from
OpenRouter's catalog is the one public price sheet, and it lists the first-party vendors (OpenAI, Anthropic) at their own standard rates. So the model is looked up by its OpenRouter id:
- An OpenRouter generator's model id already is a catalog id. Nothing to set.
- A direct-vendor generator whose id differs sets the catalog id explicitly; that is what unlocks estimation:
#![allow(unused)] fn main() { let generator = GeneratorInfo::anthropic("claude-haiku-4-5-20251001") .with_openrouter_name("anthropic/claude-haiku-4.5"); }
The only failure is a model the catalog does not know at all. Everything else produces a number.
Whose rates: the provider question
One model is served by many providers at different prices (some models span a 5x range). The rates used are:
- the serving provider's own, when the generator's provider knows its catalog slug (the built-in Anthropic and OpenAI providers do: a direct call is billed by the vendor itself);
- otherwise the dearest rate any provider of the model charges, taken bucket by bucket. That is the only figure that is a ceiling wherever OpenRouter's routing lands, and routing really does land on expensive endpoints: a real request was once billed at nearly 4x the advertised rate.
If a request pins one provider through routing settings (a single-entry order
with fallbacks off), ProviderSettings::billing_provider() yields its slug and
generator.model_rates_served_by(Some(&slug)) prices at exactly that provider.
What the figure means
The estimate is deliberately high, never a best guess: only the low side lets you overspend. It is still an estimate (tokenizers differ across model families), so treat it as a strong ceiling to reserve against, not a guarantee, and replace it with the tracked real cost once the call returns. It assumes:
- no prompt caching (caching only ever lowers the real cost);
- the largest completion the request permits, including any reasoning budget,
which providers bill on top of
max_tokens; - a minute of media for a clip whose length you did not state. Set the real
length with
AudioData::with_duration/VideoData::with_durationwhenever your prompt carries audio or video: the one-minute assumption overshoots short clips by a lot (measured live: 5-7x on an 8-second clip) and undershoots anything longer than a minute, which is the one way the estimate can come in below the real cost. With the length declared the media estimate is tight (the video token model matched Gemini's real billing within 1%). Audio bills by the second, at up to 1000x the text rate on some models, so this is where the money is.
There is no error case beyond an uncatalogued model: a prompt counted larger than the model accepts is priced as the largest input the model does accept, so you always get a number.
Keep your generators alive
Each GeneratorInfo caches the prices it fetches for an hour, and clones share
the cache. Reuse the same generator for both completions and estimates: only the
first estimate in an hour touches the network, and concurrent estimates for the
same model share a single fetch. Recreating the generator per call throws the
cache away and refetches the price sheet every time. (A generator whose model
you change notices and refetches; it never serves another model's prices.)
The library deliberately holds no registry of generators; pooling is yours to
do, and only worth doing if you estimate costs. For code that multiplexes many
models, generator.pricing_key() gives you the map key: the catalog model id
plus the provider slug, exactly the pair that determines the price. Build a
generator on a miss, reuse it on a hit:
#![allow(unused)] fn main() { use std::collections::HashMap; struct Generators(HashMap<String, GeneratorInfo>); impl Generators { fn for_model(&mut self, model: &str) -> &GeneratorInfo { let generator = GeneratorInfo::openrouter(model); self.0.entry(generator.pricing_key()).or_insert(generator) } } }
If you never estimate costs, ignore all of this: the cache sits unused and costs nothing.
Prompt Caching
Caching intent is marked on the conversation tree; the provider decides the wire.
Anthropic emits cache_control markers (honoring its 4-breakpoint cap); OpenAI
and OpenRouter auto-cache and ignore the marks. Switch the provider and the same
code works.
Mark what to cache
#![allow(unused)] fn main() { use minillmlib::ChatNode; let root = ChatNode::root("a large, stable system prompt ..."); root.cache_breakpoint(); // cache just the system prompt // ...or cache the whole stable prefix of a conversation: let some_node = root.clone(); some_node.cache_breakpoint(); }
Or, per request, auto-mark the entire prompt prefix without touching individual nodes:
#![allow(unused)] fn main() { use minillmlib::NodeCompletionParameters; let params = NodeCompletionParameters::new().with_cache(true); }
Explicit per-node marks are always honored in addition.
Clearing marks
#![allow(unused)] fn main() { use minillmlib::ChatNode; let node = ChatNode::root("x"); node.clear_cache_breakpoint(); // this node node.clear_all_cache_breakpoints(); // the whole tree }
Warming the cache
ensure_cached fires a zero-output request that writes/refreshes the cache for a
node's prefix, returning the CostInfo of the warm call. Cheap to call before an
agent run: cold pays the one-time write (which you'd pay on the next real call
anyway); warm is a cheap read that refreshes the TTL.
#![allow(unused)] fn main() { use minillmlib::{ChatNode, GeneratorInfo}; async fn run(some_node: ChatNode, generator: GeneratorInfo) -> minillmlib::Result<()> { let warm_cost = some_node.ensure_cached(&generator, None).await?; let _ = warm_cost; Ok(()) } }
Pricing cached tokens
Cache reads and writes have their own rates (read is a discount, write a premium):
#![allow(unused)] fn main() { use minillmlib::TokenPrice; let price = TokenPrice::new(1.0, 5.0) // $/Mtok input, output .with_cache_rates(0.1, 1.25); // $/Mtok cache-read, cache-write }
The three input buckets (uncached / cache-read / cache-write) are billed at their own rates; see Cost Tracking.
Custom Providers
Connecting your own server is one of two cases.
Case A: your server speaks OpenAI's /chat/completions
vLLM, llama.cpp's server, LM Studio, TGI, Ollama's OpenAI endpoint, or your own
OpenAI-compatible wrapper. Nothing custom to write: point custom() at it. The
default GenericProvider handles the wire.
#![allow(unused)] fn main() { use minillmlib::{ChatNode, GeneratorInfo, TokenPrice}; async fn run() -> minillmlib::Result<()> { // base_url is everything BEFORE /chat/completions; the provider appends the path. let gen = GeneratorInfo::custom("my-server", "http://localhost:8000/v1", "my-model") .with_api_key_from_env("MY_SERVER_KEY") // omit entirely if unauthenticated .with_header("X-Tenant", "acme") // any extra gateway headers .with_token_price(TokenPrice::new(0.0, 0.0)); // $/Mtok; 0/0 for a free local model let answer = ChatNode::root("You are helpful.") .chat("hello", &gen).await?; println!("{}", answer.message.text().unwrap_or("")); Ok(()) } }
For an older server that only accepts max_tokens (not max_completion_tokens):
#![allow(unused)] fn main() { use minillmlib::{GeneratorInfo, GenericProvider}; use std::sync::Arc; let gen = GeneratorInfo::custom("old", "http://localhost:8000/v1", "m") .with_provider(Arc::new(GenericProvider { legacy_token_limit: true })); }
If your server speaks the OpenAI envelope but its tool shape deviates,
override just the two tool hooks in your impl Provider
(openai_tools_value, openai_tool_choice_value); the rest of the default
request builder stays. See Tool Calling.
Case B: your server has a different wire
Different endpoint, auth header, request/response shape: implement the Provider
trait once and pass it via with_provider. The user-facing API
(root.chat(...)) stays identical.
Below is a complete adapter for a made-up "EchoAI" server with a genuinely
different wire: endpoint /api/generate, auth header X-Echo-Key, request
{model, prompt, settings}, response {output:{text}, meta}. This mirrors the
tested example in tests/integration_tests.rs.
#![allow(unused)] fn main() { use minillmlib::{ Auth, ChatNode, CompletionParameters, CompletionResponse, CostOutcome, GeneratorInfo, Message, MessageContent, Provider, StreamChunk, TokenPrice, Usage, }; use secrecy::ExposeSecret; use std::sync::Arc; #[derive(Debug, Clone)] struct EchoAi; impl Provider for EchoAi { fn endpoint_url(&self, base: &str) -> String { format!("{}/api/generate", base.trim_end_matches('/')) } fn auth_headers(&self, auth: &Auth) -> minillmlib::Result<Vec<(String, String)>> { Ok(match auth.secret() { Some(s) => vec![("X-Echo-Key".into(), s.expose_secret().to_string())], None => vec![], }) } fn build_request( &self, model: &str, messages: &[Message], params: &CompletionParameters, _stream: bool, _include_usage: bool, ) -> minillmlib::Result<serde_json::Value> { // Flatten the conversation into one prompt. Fail loudly on multimodal // (this wire is text-only) instead of silently dropping the attachment. let mut lines = Vec::new(); for m in messages { if let MessageContent::Parts(parts) = &m.content { if parts.iter().any(|p| p.as_text().is_none()) { return Err(minillmlib::MiniLLMError::InvalidParameter( "EchoAI is text-only".into(), )); } } lines.push(format!("{}: {}", m.role.as_str(), m.content.all_text())); } Ok(serde_json::json!({ "model": model, "prompt": lines.join("\n"), "settings": { "max_output_tokens": params.max_tokens.unwrap_or(256) }, })) } fn parse_response(&self, raw: serde_json::Value) -> minillmlib::Result<CompletionResponse> { let text = raw["output"]["text"].as_str() .ok_or_else(|| minillmlib::MiniLLMError::MalformedResponse(raw.to_string()))? .to_string(); Ok(CompletionResponse { id: raw["meta"]["id"].as_str().unwrap_or("").into(), model: raw["meta"]["model"].as_str().unwrap_or("").into(), content: text, finish_reason: raw["stop"].as_str().map(String::from), usage: self.parse_usage(&raw), tool_calls: None, media: Vec::new(), raw_response: Some(raw), }) } fn parse_usage(&self, raw: &serde_json::Value) -> Option<Usage> { let meta = raw.get("meta")?; Some(Usage { uncached_input_tokens: meta["tokens_in"].as_u64().unwrap_or(0) as u32, completion_tokens: meta["tokens_out"].as_u64().unwrap_or(0) as u32, ..Default::default() }) } fn parse_chunk(&self, _data: &str) -> Option<minillmlib::Result<StreamChunk>> { None // non-streaming } fn emits_stream_usage(&self, _requested: bool) -> bool { false // never sends a trailing usage chunk; don't wait for one } fn cost_of(&self, usage: Usage, price: Option<&TokenPrice>) -> CostOutcome { match price { Some(p) => CostOutcome::resolved(p.cost_of(&usage), usage), None => CostOutcome::unpriced(usage), } } } async fn run() -> minillmlib::Result<()> { let gen = GeneratorInfo::custom("echoai", "https://my.host", "echo-1") .with_provider(Arc::new(EchoAi)) .with_api_key("my-secret") .with_token_price(TokenPrice::new(1.0, 5.0)); let answer = ChatNode::root("You are EchoAI.") .chat("hello", &gen).await?; let _ = answer; Ok(()) } }
What to override
The trait defaults to the OpenAI dialect, so you override only what differs:
| Method | Override when |
|---|---|
endpoint_url | the path isn't /chat/completions |
auth_headers | auth isn't Authorization: Bearer |
build_request | the request body isn't the OpenAI shape |
parse_response | the response envelope isn't choices[] |
parse_chunk | streaming chunks aren't OpenAI deltas (return None if non-streaming) |
parse_usage | usage fields differ |
emits_stream_usage | the server may never send a trailing usage chunk (return false, or the stream waits for one that never comes) |
parse_response_media | the wire returns model-generated media somewhere other than message.images |
cost_of | cost is derived differently |
resolve_post_stream | there's an out-of-band cost endpoint |
Reusing the OpenAI dialect piecewise
The shared dialect the defaults delegate to is public:
minillmlib::provider::openai_wire holds the request builder, the
envelope/usage/chunk parsers, and the wire projections of the normalized
types (messages_to_payload, tool_definition_value, ...). A provider
that is mostly OpenAI-shaped can override one method and still call
these for everything it does not change, instead of copy-pasting the
dialect.
Two rules to copy from the example
- Fail loudly on anything you can't represent. EchoAI rejects multimodal rather than silently flattening it away.
- Override
emits_stream_usagetofalseif your server never sends a trailing usage chunk, or a streaming call will wait for it until the idle timeout.
Claude Subscription
Use your Claude Pro/Max subscription instead of a pay-as-you-go API key. A subscription OAuth token authenticates against the same native Anthropic API as an API key, but draws on your subscription's rolling quota (the 5-hour / 7-day window) rather than API billing.
#![allow(unused)] fn main() { use minillmlib::{ChatNode, GeneratorInfo, TokenPrice}; async fn run() -> minillmlib::Result<()> { // Anthropic returns token counts but no dollar cost, so set a price for a // resolved cost ESTIMATE (otherwise tracking reports `Unpriced`). let generator = GeneratorInfo::claude_subscription("claude-haiku-4-5") .with_token_price(TokenPrice::new(1.0, 5.0)); // $/Mtok in, $/Mtok out let root = ChatNode::root("You are helpful."); let response = root.chat("Hello!", &generator).await?; let _ = response; Ok(()) } }
How the token is resolved
claude_subscription resolves the bearer token in this order:
- the
ANTHROPIC_AUTH_TOKENenv var, if set (explicit override; you keep it fresh, e.g. fromclaude setup-token); - otherwise the live Claude Code credential at
~/.claude/.credentials.json(claudeAiOauth.accessToken), which Claude Code keeps refreshed, so if you're logged into Claude Code with your subscription, it just works.
If neither source yields a token, the request fails loudly as unauthenticated rather than silently using the wrong account.
Subscription vs Console
A subscription token (from Claude Code) bills your Pro/Max plan. A Console/API OAuth token bills your API account, not the subscription. For Console use an API key via
GeneratorInfo::anthropic(model), and this preset only for the actual Pro/Max subscription token.
Cost is always an ESTIMATE here: Anthropic returns only token counts, so the
TokenPrice you set (reflecting the model's published price) produces a
Resolved USD estimate; without it, tracking reports Unpriced.
API Reference
The full, auto-generated API reference (every public type, method, and signature) lives on docs.rs:
It is generated from the source doc comments and rebuilt automatically when a new version is published to crates.io. This guide covers how to use the library; the docs.rs reference is the exhaustive signature lookup.