llm-universal
One async Rust client for many LLM APIs. Write your chat, streaming and tool-calling code once and switch providers by switching the constructor.
| Provider | Constructor | API |
|---|---|---|
| Anthropic (Claude) | AnthropicProvider::new(key) |
Messages API |
| Claude on Vertex AI | AnthropicProvider::vertex(project, region, access_token) |
rawPredict |
| OpenAI | OpenAiProvider::new(key) |
Chat Completions |
| Google Gemini | GeminiProvider::new(key) |
generateContent |
| Gemini on Vertex AI | GeminiProvider::vertex(project, region, access_token) |
generateContent |
| Cohere | CohereProvider::new(key) |
Chat API v2 |
| Azure OpenAI | OpenAiProvider::azure(resource, key) |
Azure OpenAI v1 (api-key header) |
| Amazon Bedrock | OpenAiProvider::bedrock(region, bedrock_api_key) |
Bedrock OpenAI-compatible endpoint |
| Groq | OpenAiProvider::groq(key) |
OpenAI-compatible |
| Mistral | OpenAiProvider::mistral(key) |
OpenAI-compatible |
| DeepSeek | OpenAiProvider::deepseek(key) |
OpenAI-compatible |
| xAI (Grok) | OpenAiProvider::xai(key) |
OpenAI-compatible |
| OpenRouter | OpenAiProvider::openrouter(key) |
OpenAI-compatible |
| Together AI | OpenAiProvider::together(key) |
OpenAI-compatible |
| Fireworks AI | OpenAiProvider::fireworks(key) |
OpenAI-compatible |
| Perplexity | OpenAiProvider::perplexity(key) |
OpenAI-compatible |
| Ollama (local) | OpenAiProvider::ollama() |
OpenAI-compatible |
| LM Studio (local) | OpenAiProvider::lm_studio() |
OpenAI-compatible |
| Anything else OpenAI-compatible (vLLM, LiteLLM, …) | OpenAiProvider::new(key).with_base_url(url) |
OpenAI-compatible |
Features
| Feature | How |
|---|---|
| Chat | provider.chat(&req) |
| Streaming | provider.chat_stream(&req) → StreamEvent::TextDelta, …, StreamEvent::Done(response) |
| Tool calling | Tool::new(...), response.tool_calls(), or the whole loop with run_tools(...) |
| Images | Message::user_with_image(text, Part::image_url(url)) / Part::image_base64(type, data) |
| Structured output | .json_schema(name, schema) then response.json::<T>() |
| Reasoning / thinking | .reasoning(ReasoningEffort::High), read response.reasoning() |
| Embeddings | EmbeddingProvider::embed(&provider, model, &inputs) (OpenAI & compatible, Gemini, Cohere) |
| Model list | provider.list_models() |
| Retries | automatic, like the official SDKs; .with_max_retries(n) |
| Cost | response.usage.cost(input_price_per_million, output_price_per_million) |
Safe defaults:
- Connect timeout, idle read timeout, a 32 MiB response cap and a 4 MiB cap per stream event.
- No redirects, so the key never goes to another host.
- Keys are refused over plain
http://to non-local hosts. - Keys are redacted from
Debugand marked sensitive in headers. - URLs are stripped from connection errors.
- Model, project and region are checked before they go into a URL.
Install
[]
= "0.3"
= { = "1", = ["macros", "rt-multi-thread"] }
Quick start
use ;
async
Streaming
# use *;
# async
Tool calling
run_tools sends the request, runs your handler for every tool call, sends the results back and
repeats until the model answers:
# use *;
# use json;
# async
The manual loop: check response.tool_calls(), push response.message to req.messages, then
push Message::new(Role::Tool, results) built with Part::tool_result(id, content, is_error).
Images
# use *;
# async
Part::image_url(url) works with OpenAI, Anthropic and Cohere; Gemini needs base64 data.
Structured output
# use *;
# use json;
# async
Errors
LlmError variant |
When | SDK equivalent |
|---|---|---|
Api(Box<ApiError>) |
non-2xx response (or an error event inside a stream) | APIStatusError and subclasses |
Connection |
DNS, TLS, dropped connection | APIConnectionError |
Timeout |
request timed out | APIConnectionTimeoutError |
Deserialize |
body is not JSON (content keeps the start of it; not printed) |
JSONDeserialize (async-openai) |
InvalidResponse |
JSON is missing expected fields, or a size cap was hit | — |
InvalidArgument |
client-side validation failed | InvalidArgument (async-openai) |
ApiError carries status, kind (ApiErrorKind::RateLimit, …), message, error_type,
code, param, request_id, retry_after and the JSON body. The message format matches the SDKs:
401 invalid x-api-key
429 Rate limit reached for requests
invalid args: model must not be empty
Retries follow the SDKs: 2 retries by default on connection errors, timeouts, 408, 409, 429 and
= 500.
retry-after/retry-after-ms(up to 60 s) andx-should-retryare honored; otherwise the delay is 0.5 s doubling up to 8 s, with jitter.err.is_retryable()tells you whether a final error is worth retrying later.
Notes
- Anthropic's newer models accept only
temperature = 1.0; leavetemperatureunset unless you need it. - Reasoning blocks (
Part::Reasoning) and Gemini'sthoughtSignatureare kept in the message and sent back unchanged on the next turn, as Anthropic and Gemini require. Keepresponse.messagein the history. - Gemini tool calls without an id get a local id (
llm-universal-call-N) that is never sent back to the API. - Vertex AI needs an OAuth access token (for example
gcloud auth print-access-token); tokens expire after about an hour. Model lists and Gemini embeddings are not available on Vertex in this crate. - Bedrock uses the OpenAI-compatible endpoint with a Bedrock API key; SigV4 signing is not implemented.
License
MIT-0 (MIT No Attribution): use, copy, modify and sell it for any purpose, commercial included, with no attribution required.