# Mirascope - the LLM Anti-Framework
The LLM library space is crowded, so why did we build another one?
We feel that the LLM dev space is kind of like web development, before React. You can use the "raw APIs" (direct DOM manipulation, or raw provider SDKs). You get full control, but there are rough edges. Or you can depend on a framework (Angular or Backbone, or LangChain and PydanticAI). These provide power and convenience, but you lose control over precisely how your code operates.
We think there's room for the "React of LLM Developing": something that is abstracted just enough to provide a much better developer experience, but is a library, not a framework, and leaves you in full control over how your code operates. Before we dive into Mirascope, let's look at what already exists:
## Developing with LLMs: Three Imperfect Options
Suppose you're writing code that calls a frontier LLM, like Opus, Gemini-3, or GPT-5. You have three options:
### 1. Use the provider's native APIs
The advantage: You can take best advantage of that provider's exact interface, and its nuances.
The disadvantage: You're vendor locked into a particular provider. Next month a different LLM may be top of the rankings, and you'd need to rewrite to use it. Also, each provider-native interface has unique pain points, and the type safety leaves something to be desired.
### 2. Use an OpenAI Completions compatibility layer
Because the "Chat Completions" API was the first mover, the other major model providers have compatibility layers. So, you can treat OpenAI Completions as the standard, write code against just it, and still run other models. This is the approach OpenRouter and LiteLLM take.
This solves for vendor lock-in, but comes with its own issues. The openai compatibility layer is *not* first-class provider support. For example, Anthropic [explicitly says](https://platform.claude.com/docs/en/api/openai-sdk) that they suggest using it to easily evaluate Claude, but not for production use cases. It is missing crucial features like configuring prompt caching, and viewing model thought processes.
OpenAI itself treats Completions as a legacy interface. They [recommend all new projects move to the Responses API](https://platform.openai.com/docs/guides/migrate-to-responses), and features like their own models' reasoning traces are only available via Responses. It's clear from OpenAI's own choices that using Completions is not a future-oriented solution.
### 3. Use an LLM Framework like Langchain or Pydantic-AI
The third option is to forgo direct control over the LLMs, and hand off to an LLM agent framework. Langchain and PydanticAI are two of the best known.
This approach has some major benefits:
- Their interfaces are unified across LLM providers
- They have better type safety and ergonomics (especially around structured outputs)
- They are easier to get started with
However, you still have a kind of lock-in: namely, abstraction lock in. By using these frameworks, you are buying into their model of what an "agent" is. You lose fine-grained control over precisely what the LLM is doing, and shift up the stack to configuring an agent and calling `agent.invoke()` or `agent.run()`.
This is great while you're on the abstraction designer's happy path. But all abstractions are leaky, and that leakiness comes at the expense of control.
There's a reason that the web development world moved on from frameworks like Angular, and to hands-on abstractions like React.
## Enter Mirascope
Mirascope is designed as a lightweight LLM library—or, cheekily, an "Anti-Framework".
The north star we've aimed for is writing an LLM library that offers a low-level, type-safe, provider-agnostic, and composable and flexible toolkit. You should always have the option to be directly in control of each message, content block, and LLM call, without it being a hassle.
Mirascope's core concepts are llm.Models that can be called to get llm.Responses, and if you want to add tools, structured outputs, async, streaming, or MCP, everything just clicks together nicely. It's not an agent framework. You won't need one, because you can easily implement the exact agent logic you want, yourself.
Enough talking. Let's see some code!
```python
from mirascope import llm
model: llm.Model = llm.Model("anthropic/claude-sonnet-4-5")
response: llm.Response = model.call("Please recommend a fantasy book")
print(response.text())
# > I'd recommend The Name of the Wind by Patrick Rothfus...
```
If you want streaming, you can just call `model.stream` and then iterate over the response content:
```python
from mirascope import llm
model: llm.Model = llm.Model("anthropic/claude-sonnet-4-5")
response: llm.StreamResponse = model.stream("Do you think Pat Rothfuss will ever publish Doors of Stone?")
for chunk in response.text_stream():
print(chunk, flush=True, end="")
```
Each response has the full message history, which means you can continue generation by calling `response.resume`:
```python
from mirascope import llm
response = llm.Model("openai/gpt-5-mini").call("How can I make a basil mint mojito?")
print(response.text())
response = response.resume("Is adding cucumber a good idea?")
print(response.text())
```
The `resume` pattern is a cornerstone of the library, since it abstracts state tracking in a very predictable way. It's future-proofed with where LLM APIs are headed, because it automatically handles "stateful resumes" that maintain the provider's server-side reasoning state.
It also makes tool calling a breeze. You define tools via the `@llm.tool` decorator, and invoke them directly via the response.
```python
from mirascope import llm
@llm.tool
def exp(a: float, b: float) -> float:
"""Compute an exponent"""
return a ** b
model = llm.Model("anthropic/claude-haiku-4-5")
response = model.call("What is (42 ** 3) ** 2?", tools=[exp])
while response.tool_calls:
print(f"Calling tools: {response.tool_calls}")
tool_outputs = response.execute_tools()
response = response.resume(tool_outputs)
print(response.text())
```
The `llm.Response` class also allows handling structured outputs in a typesafe way, as it's generic on the structured output format. We support primitive types as well as Pydantic `BaseModel` out of the box:
```python
from mirascope import llm
from pydantic import BaseModel
class Book(BaseModel):
title: str
author: str
recommendation: str
model: llm.Model = llm.Model("anthropic/claude-sonnet-4-5")
response: llm.Response[Book] = model.call("Please recommend a fantasy book", format=Book)
book: Book = response.parse()
print(book)
```
The upshot is that if you want to do something sophisticated—like a streaming tool calling agent—you don't need a framework, you can just compose all these primitives. The `streams()` method yields separate sub-streams for each content part (text, thoughts, tool calls), giving you fine-grained control over how to handle each type:
```python
from mirascope import llm
@llm.tool
def exp(a: float, b: float) -> float:
"""Compute an exponent"""
return a ** b
@llm.tool
def add(a: float, b: float) -> float:
"""Add two numbers"""
return a + b
model = llm.Model("anthropic/claude-haiku-4-5")
response = model.stream("What is 42 ** 4 + 37 ** 3?", tools=[exp, add])
while True:
for stream in response.streams():
if stream.content_type == "text":
for delta in stream:
print(delta, end="", flush=True)
elif stream.content_type == "tool_call":
stream.collect() # consume the stream
print(f"\n> Calling {stream.tool_name}({stream.partial_args})")
print()
if response.tool_calls:
response = response.resume(response.execute_tools())
else:
break
```
Mirascope is also open-source (MIT licensed), and exhaustively tested (100% coverage, and E2E tests with snapshotted provider responses for every supported feature and every supported provider).
You can read more in the [docs](https://mirascope.com/docs/), see the source on [GitHub](https://github.com/Mirascope/mirascope/tree/main/python), or join our [Discord](https://mirascope.com/discord-invite).