llmprism
llmprism is a Rust library for talking to Large Language Model providers -- 13 of
them, from OpenAI and Anthropic to Gemini, Ollama, and more -- through one
consistent, fluent API instead of learning each provider's own SDK and JSON shape.
It's a Rust port of the PHP/Laravel library Prism, rebuilt
around Rust's async ecosystem and type system.
use Registry;
async
Swap "openai" for "anthropic" and the rest of the code doesn't change -- that's
the whole point. Call .stream() instead of .generate() to get the reply
incrementally as a stream of StreamEvents rather than waiting for the whole
thing -- tool calling works exactly the same way either way.
Status
This crate is early (pre-1.0) and the public API may still change, but every provider and capability in the original scope is implemented. Not every provider supports every capability -- a provider's own API has to actually offer it -- so here's what works where:
| Provider | Text + tools + streaming | Structured output | Moderation | Embeddings | Rerank | Images | Audio |
|---|---|---|---|---|---|---|---|
| OpenAI | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | |
| Anthropic | ✅ | ✅ | |||||
| Gemini | ✅ | ✅ | ✅ | ||||
| Groq, DeepSeek, Mistral, xAI, OpenRouter, Perplexity, Z.ai | ✅ | ||||||
| Ollama | ✅ | ✅ | |||||
| VoyageAI | ✅ | ✅ | |||||
| ElevenLabs | ✅ |
A few notes on the gaps:
- Anthropic has no moderation, embeddings, or image endpoints to call at
all; Gemini has no moderation or image endpoints. Calling one of those
methods on a provider that doesn't have it returns
Error::Unsupportedrather than failing to compile. - Groq, DeepSeek, Mistral, xAI, OpenRouter, Perplexity, and Z.ai are thin
wrappers around the OpenAI provider pointed at each vendor's own
OpenAI-compatible endpoint. Text generation (including tools and streaming)
is the one capability guaranteed to actually be compatible across all of
them -- see the
providers::openai_compatiblemodule docs (cargo doc --open) for why the rest are deliberately not wired up for this family. - Ollama also reuses the OpenAI provider (plus embeddings), needs no API key
by default, and -- unlike every other provider -- is registered by
Registry::from_env()unconditionally rather than only when a key is set. - VoyageAI and ElevenLabs are retrieval/audio specialists: VoyageAI does embeddings and reranking (frequently paired with Anthropic, which has neither of its own), and ElevenLabs does only text-to-speech and speech-to-text.
Installing
[]
= { = "0.3", = ["openai", "anthropic"] }
Nothing is enabled by default. Every provider lives behind its own Cargo feature
flag -- openai, anthropic, groq, deepseek, mistral, xai,
openrouter, perplexity, zai, ollama, voyageai, elevenlabs,
gemini -- so
your binary only pulls in the HTTP client and pays the compile-time cost for
the providers you actually use. Turn on everything with the full feature.
How it's organized
Registryis where you register providers (or letRegistry::from_env()do it for you) and it's the one place application code asks for a provider by name. In tests you register aFakeProviderunder the same name instead -- no other code has to change.- Every capability -- Text, structured output, moderation,
embeddings, reranking, images, and audio (text-to-speech
and speech-to-text) -- follows the same shape: call a method on the
registry to
get a fluent builder, chain
.with_*()calls to describe the request, then run it --.generate()(all at once) or.stream()(incrementally, as aStreamof events) for Text,.generate()for the others. Not every provider implements every capability; calling one that doesn't returnsError::Unsupportedrather than failing to compile. - Tools are anything implementing the
Tooltrait. Give a request a list of tools andllmprismhandles the whole back-and-forth automatically: if the model asks to call a tool, the tool runs, its result is sent back to the model, and this repeats (up towith_max_steps) until the model produces a final answer. Schemadescribes the shape of a tool's arguments -- or, forRegistry::structured, the exact shape a reply must match -- in a way every provider understands, without you hand-writing each provider's particular JSON Schema dialect. OpenAI and Anthropic get there differently under the hood (a native enforced response format vs. a forced tool call); you get the sameStructuredResponseback either way.ProviderMiddlewarewraps a registered provider to intercept its calls from outside its own implementation -- logging, caching, redaction, a default system prompt, or short-circuiting a call entirely. Attach one withRegistry::wrap; middlewares compose.persistence::PersistenceMiddleware(built onProviderMiddleware) saves a conversation's message history after each call and reloads it before the next one, keyed by an opaque id you set withPendingTextRequest::with_conversation_id-- so a caller only has to send the newest turn instead of replaying the whole conversation every time. Ships with an in-memory referenceConversationStore; a real backend (Postgres, Redis, ...) is a small trait impl away.tenancy::TenantRegistry/UsageTrackingMiddlewareare the hooks for serving several tenants from one process -- resolve aRequestContext(tenant id, user id, free-form claims your own auth attaches) to theRegistrythat tenant should use, and record per-tenant token usage after every round trip. This crate never verifies identity itself; that stays your application's own auth.
Run cargo doc --open --all-features for the full reference -- every public type
has a plain-language explanation of what it's for and, where it helps, a short
example.
Framework integrations
Building an actual API server on top of llmprism? You don't have to
hand-wire routes yourself. llmprism-axum is a
companion crate that turns a Registry into a working Axum Router in one
line:
use Registry;
let registry = from_env;
let app = routes; // an ordinary axum::Router
That gets you POST /v1/text (plus a /v1/text/stream SSE variant),
/v1/structured (+ /v1/structured/stream), /v1/moderation,
/v1/embeddings, /v1/rerank, /v1/images, and /v1/audio/speech +
/v1/audio/transcriptions -- each taking the same shape of request
PendingXRequest's .with_*() builders accept, and returning the exact
response type the matching Registry method would. The Router it
returns composes normally: .merge() it into a bigger application, layer
your own tower middleware on top (auth, rate limiting, tracing), or
serve it as-is.
Deliberately not included: tool calling, approval handling, and MCP have no
JSON wire representation (a Tool is arbitrary server-side code), so those
stay something your server configures directly against Registry rather
than something a client can request. See
crates/llmprism-axum's own README for the full
route reference and worked curl examples.
Serving several tenants from one process? llmprism_axum::routes_multi_tenant(tenant_registry)
resolves the Registry to use per request, from a tenancy::RequestContext
your own auth middleware attaches -- see the persistence/tenancy bullets
above and that crate's own README for the details. This crate never
verifies identity itself; that's squarely your application's job.
Adapters for other frameworks (Actix-web, Rocket) are what's left on the
roadmap. See ROADMAP.md for the full picture and the
reasoning behind each phase, including why the core llmprism crate itself
will never gain a hard dependency on any web framework -- adapters are
separate, optional crates so a consumer who wants neither pays for neither.
Examples
The examples/ directory has runnable examples covering text
generation, streaming, tool calling, structured output, and testing your own
code with FakeProvider (the only one that needs no API key -- try that one
first):
OPENAI_API_KEY=sk-...
OPENAI_API_KEY=sk-...
OPENAI_API_KEY=sk-...
OPENAI_API_KEY=sk-...
ANTHROPIC_API_KEY=sk-ant-...
ANTHROPIC_API_KEY=sk-ant-...
ANTHROPIC_API_KEY=sk-ant-...
ANTHROPIC_API_KEY=sk-ant-...
The OpenAI and Anthropic examples are otherwise identical pairs -- nothing about the OpenAI ones is actually OpenAI-specific, swapping the provider name and Cargo feature is the only difference, which is exactly the point: the Anthropic set exists mainly so you don't have to make that edit yourself. The same swap works for any other registered provider too.
Command-line usage
Every capability is also reachable from the shell, via the cli feature --
a thin wrapper around the same Registry::from_env() every Rust consumer
uses, so it needs no provider-specific setup beyond the feature flags and
API keys you'd already need in code:
OPENAI_API_KEY=sk-...
OPENAI_API_KEY=sk-...
OPENAI_API_KEY=sk-...
OPENAI_API_KEY=sk-...
Every text-taking flag (-P/--prompt, -i/--input) falls back to
reading stdin when omitted, so commands compose the normal Unix way:
echo "..." | llmprism text -p openai -m gpt-4o-mini. Add --json to any
command for machine-readable output instead of the plain-text summary.
With the mcp feature also enabled, text/stream gain repeatable
--mcp-stdio "<command and args>" / --mcp-http <url> flags that connect
to an MCP server and attach its tools before generating -- see
examples/mcp_tool_calling.rs for the same
thing from Rust code. Run llmprism --help (or llmprism <command> --help) for the full flag list; every subcommand maps directly to one
Registry capability.
Testing your own code against this crate
You don't need real API keys to test code that uses llmprism. Register a
FakeProvider with scripted responses instead of a real provider:
use ;
use Registry;
#
# async
Development
RUSTDOCFLAGS="-D warnings"
Every provider has its own live smoke test under tests/ (openai_text.rs,
anthropic_text.rs, gemini.rs, and so on) that only runs when that
provider's API key is set in your environment; without it, the test prints a
note and passes trivially, so CI needs no secrets. .github/workflows/ci.yml
also builds every provider feature completely on its own
(--no-default-features --features X, one job per provider) -- full alone
isn't enough to catch a feature-gated item that only compiles because some
other enabled feature happens to pull in what it needs.
Before publishing a new version, cargo publish --dry-run packages and
compiles the crate exactly as crates.io would, without actually uploading
anything. See scripts/release.sh for the actual
release steps, CHANGELOG.md for what's shipped in each
version, and ROADMAP.md for where framework integration
(Axum, persistence, multi-tenancy) is headed next.
License
MIT -- see LICENSE.