Mirascope Tutorial: A Pythonic Toolkit for Building LLM Applications
Introduction
Mirascope is a Python library that provides a clean, Pythonic interface for interacting with Large Language Models (LLMs). Unlike heavyweight frameworks such as LangChain that introduce multiple layers of abstraction, Mirascope takes a minimalist approach by leveraging native Python features like decorators, type hints, and Pydantic models.
Mirascope's key strength lies in its ability to simplify prompt engineering, structured output extraction, and tool calling without sacrificing flexibility. The library supports multiple LLM providers including OpenAI, Anthropic, Google Gemini, Mistral, Groq, and Cohere through a single consistent API.
In this tutorial, we'll learn how to use Mirascope from installation, basic prompt engineering, structured output, tool calling, to advanced techniques like chaining, streaming, and response model validation.
Why Mirascope?
Before diving into implementation, here's why Mirascope is worth considering:
Installation
Basic Installation
Mirascope can be installed using pip with your desired provider:
# Install core mirascope
pip install mirascope
Install with specific providers
pip install "mirascope[openai]"
pip install "mirascope[anthropic]"
pip install "mirascope[gemini]"
pip install "mirascope[groq]"
Install with all providers
pip install "mirascope[all]"
Setting Up Environment Variables
Configure API keys for your chosen provider:
# OpenAI
export OPENAIAPIKEY="sk-your-openai-key"
Anthropic
export ANTHROPICAPIKEY="sk-ant-your-anthropic-key"
Google Gemini
export GOOGLEAPIKEY="your-google-key"
Groq
export GROQAPIKEY="your-groq-key"
Or use a .env file with python-dotenv:
pip install python-dotenv
from dotenv import loaddotenv
load
dotenv()
Verify Installation
import mirascope
print(f"Mirascope version: {mirascope.version}")
Basic Usage: Prompt Engineering
Creating Simple Prompts with Decorators
Mirascope uses the @prompttemplate decorator and call functions to generate LLM responses:
from mirascope.core import openai, prompttemplate
@openai.call("gpt-4o-mini")
@prompttemplate("Explain {topic} in 3 paragraphs")
def explain(topic: str): ...
response = explain("machine learning")
print(response.content)
The code above is remarkably concise. The @openai.call decorator specifies the provider and model, while @prompttemplate defines the prompt template. The topic parameter is automatically injected into the template.
Multi-Provider Support
One of Mirascope's key advantages is the ease of switching between providers:
from mirascope.core import openai, anthropic, gemini, prompttemplate
@openai.call("gpt-4o-mini")
@prompt
template("What is {concept}?")
def askopenai(concept: str): ...
@anthropic.call("claude-sonnet-4-20250514")
@prompttemplate("What is {concept}?")
def askanthropic(concept: str): ...
@gemini.call("gemini-1.5-flash")
@prompttemplate("What is {concept}?")
def askgemini(concept: str): ...
All functions share the same interface
for askfn in [askopenai, askanthropic, askgemini]:
response = askfn("neural network")
print(f"[{askfn.name}]: {response.content[:100]}...")
System Prompts and Messages
For more complex scenarios, you can use messages directly:
from mirascope.core import openai, Messages
@openai.call("gpt-4o-mini")
def translate(text: str, targetlang: str) -> Messages.Type:
return [
Messages.System(
"You are a professional translator. "
"Translate the given text accurately and naturally."
),
Messages.User(
f"Translate to {targetlang}: {text}"
),
]
result = translate("Selamat pagi, apa kabar?", "English")
print(result.content)
Dynamic Prompts with Computed Fields
from mirascope.core import openai, prompttemplate
@openai.call("gpt-4o-mini")
@prompttemplate(
"""
SYSTEM: You are an expert data analyst assistant.
USER:
Analyze the following dataset and provide key insights:
Columns: {columns}
Row count: {rowcount}
Sample data:
{sampledata}
"""
)
def analyzedataset(
columns: list[str],
rowcount: int,
sampledata: str,
): ...
response = analyzedataset(
columns=["name", "age", "salary", "department"],
rowcount=1500,
sampledata="Ahmad, 28, 8500000, Engineering\nSiti, 32, 12000000, Management",
)
print(response.content)
Structured Output: Extracting Structured Data
The most powerful feature of Mirascope is its ability to extract structured data using Pydantic models.
Basic Extraction
from pydantic import BaseModel, Field
from mirascope.core import openai, prompttemplate
class BookInfo(BaseModel):
title: str = Field(description="Book title")
author: str = Field(description="Author name")
year: int = Field(description="Publication year")
genre: str = Field(description="Book genre")
summary: str = Field(description="Brief summary in 1-2 sentences")
@openai.call("gpt-4o-mini", responsemodel=BookInfo)
@prompttemplate("Extract information from the following book review: {review}")
def extractbookinfo(review: str): ...
review = """
'Sapiens: A Brief History of Humankind' by Yuval Noah Harari, published in 2011,
is a groundbreaking non-fiction work that explores the history of the human species
from the Stone Age to the present day. This history book became an international
bestseller and has been translated into over 60 languages.
"""
book = extractbookinfo(review)
print(f"Title: {book.title}")
print(f"Author: {book.author}")
print(f"Year: {book.year}")
print(f"Genre: {book.genre}")
print(f"Summary: {book.summary}")
Nested Models and Lists
from pydantic import BaseModel, Field
from mirascope.core import openai, prompttemplate
class Ingredient(BaseModel):
name: str = Field(description="Ingredient name")
amount: str = Field(description="Quantity")
unit: str = Field(description="Unit of measurement")
class RecipeStep(BaseModel):
stepnumber: int = Field(description="Step number")
instruction: str = Field(description="Cooking instruction")
durationminutes: int | None = Field(
default=None, description="Duration in minutes if applicable"
)
class Recipe(BaseModel):
name: str = Field(description="Dish name")
servings: int = Field(description="Number of servings")
preptimeminutes: int = Field(description="Preparation time in minutes")
cooktimeminutes: int = Field(description="Cooking time in minutes")
ingredients: list[Ingredient] = Field(description="List of ingredients")
steps: list[RecipeStep] = Field(description="Cooking steps")
tips: list[str] = Field(description="Cooking tips")
@openai.call("gpt-4o", responsemodel=Recipe)
@prompttemplate("Provide a complete recipe for: {dishname}")
def getrecipe(dishname: str): ...
recipe = getrecipe("Thai Green Curry")
print(f"Recipe: {recipe.name}")
print(f"Servings: {recipe.servings}")
print(f"Cook time: {recipe.cooktimeminutes} minutes")
print(f"\nIngredients ({len(recipe.ingredients)}):")
for ing in recipe.ingredients:
print(f" - {ing.name}: {ing.amount} {ing.unit}")
print(f"\nSteps ({len(recipe.steps)}):")
for step in recipe.steps:
print(f" {step.stepnumber}. {step.instruction}")
Validation with Pydantic
Since Mirascope uses Pydantic, you get data validation for free:
from pydantic import BaseModel, Field, fieldvalidator
from mirascope.core import openai, prompt
template
class SentimentResult(BaseModel):
text: str = Field(description="The analyzed text")
sentiment: str = Field(description="positive, negative, or neutral")
confidence: float = Field(
description="Confidence score between 0 and 1", ge=0, le=1
)
keyphrases: list[str] = Field(
description="Key phrases that determine the sentiment"
)
@fieldvalidator("sentiment")
@classmethod
def validatesentiment(cls, v: str) -> str:
allowed = {"positive", "negative", "neutral"}
if v.lower() not in allowed:
raise ValueError(f"Sentiment must be one of: {allowed}")
return v.lower()
@openai.call("gpt-4o-mini", responsemodel=SentimentResult)
@prompttemplate("Analyze the sentiment of the following text: {text}")
def analyzesentiment(text: str): ...
result = analyzesentiment(
"This product is amazing! The quality is outstanding and delivery was fast."
)
print(f"Sentiment: {result.sentiment} (confidence: {result.confidence:.2%})")
print(f"Key phrases: {', '.join(result.keyphrases)}")
Tool Calling: Integrating Python Functions
Mirascope allows LLMs to call Python functions directly, enabling integration with external APIs, databases, or business logic.
Defining Tools
from mirascope.core import openai, prompttemplate, BaseTool
from pydantic import Field
import json
class GetWeather(BaseTool):
"""Get weather information for a city."""
city: str = Field(description="City name")
country: str = Field(default="US", description="Country code (ISO 3166)")
def call(self) -> str:
# Simulated weather API
weather
data = {
"New York": {"temp": 22, "condition": "Partly Cloudy", "humidity": 65},
"London": {"temp": 15, "condition": "Rainy", "humidity": 80},
"Tokyo": {"temp": 28, "condition": "Sunny", "humidity": 55},
}
data = weatherdata.get(
self.city, {"temp": 20, "condition": "Unknown", "humidity": 50}
)
return json.dumps({"city": self.city, "country": self.country, *data})
class CalculateDistance(BaseTool):
"""Calculate distance between two cities."""
cityfrom: str = Field(description="Origin city")
cityto: str = Field(description="Destination city")
def call(self) -> str:
distances = {
("New York", "London"): 5570,
("New York", "Tokyo"): 10838,
("London", "Tokyo"): 9566,
}
key = (self.cityfrom, self.cityto)
reversekey = (self.cityto, self.cityfrom)
dist = distances.get(key, distances.get(reversekey, 0))
return f"Distance {self.cityfrom} - {self.cityto}: {dist} km"
@openai.call("gpt-4o-mini", tools=[GetWeather, CalculateDistance])
@prompttemplate("{query}")
def travelassistant(query: str): ...
response = travelassistant(
"What's the weather like in New York and London? "
"How far apart are they?"
)
if response.tools:
for tool in response.tools:
result = tool.call()
print(f"Tool: {tool.class.name}")
print(f"Result: {result}\n")
else:
print(response.content)
Tool Loop for Multi-Step Reasoning
from mirascope.core import openai, Messages, BaseTool
from pydantic import Field
class SearchDatabase(BaseTool):
"""Search employee data in the database."""
query: str = Field(description="Search keyword")
department: str | None = Field(
default=None, description="Filter by department"
)
def call(self) -> str:
employees = [
{"name": "Alice", "dept": "Engineering", "salary": 120000},
{"name": "Bob", "dept": "Marketing", "salary": 95000},
{"name": "Charlie", "dept": "Engineering", "salary": 135000},
{"name": "Diana", "dept": "HR", "salary": 85000},
]
if self.department:
employees = [e for e in employees if e["dept"] == self.department]
return str(employees)
class CalculateAverage(BaseTool):
"""Calculate the average of a list of numbers."""
numbers: list[float] = Field(description="List of numbers to average")
def call(self) -> str:
avg = sum(self.numbers) / len(self.numbers)
return f"Average: {avg:,.2f}"
tools = [SearchDatabase, CalculateAverage]
def hrassistant(query: str) -> str:
messages = [
Messages.System(
"You are an HR assistant. Use the available tools "
"to answer questions about employee data."
),
Messages.User(query),
]
@openai.call("gpt-4o-mini", tools=tools)
def call(messages: list) -> list:
return messages
response = call(messages)
while response.tools:
toolresults = []
for tool in response.tools:
result = tool.call()
toolresults.append(
Messages.Tool(toolcallid=tool.toolcall.id, content=result)
)
messages = [
messages,
response.messageparam,
toolresults,
]
response = call(messages)
return response.content
answer = hrassistant(
"What is the average salary in the Engineering department?"
)
print(answer)
Advanced Usage
Streaming Responses
For long responses, streaming provides a better user experience:
from mirascope.core import openai, prompttemplate
@openai.call("gpt-4o-mini", stream=True)
@prompt
template("Write a short story about {topic}")
def writestory(topic: str): ...
stream = writestory("a robot learning to cook")
for chunk, in stream:
print(chunk.content, end="", flush=True)
print()
Access metadata after streaming completes
print(f"\nTokens used: {stream.inputtokens} input, {stream.outputtokens} output")
Streaming Structured Output
from pydantic import BaseModel, Field
from mirascope.core import openai, prompttemplate
class ArticleOutline(BaseModel):
title: str = Field(description="Article title")
sections: list[str] = Field(description="List of article sections")
targetaudience: str = Field(description="Target audience")
estimatedwordcount: int = Field(description="Estimated word count")
@openai.call("gpt-4o-mini", responsemodel=ArticleOutline, stream=True)
@prompttemplate("Create an article outline about: {topic}")
def createoutline(topic: str): ...
stream = createoutline("CI/CD Implementation for Machine Learning")
for partialoutline in stream:
if partialoutline.title:
print(f"Title: {partialoutline.title}")
if partialoutline.sections:
print(f"Sections so far: {len(partialoutline.sections)}")
finaloutline = stream.constructedresponsemodel
print(f"\nFinal outline: {finaloutline.modeldumpjson(indent=2)}")
Chaining Calls
Mirascope makes it easy to chain multiple LLM calls:
from pydantic import BaseModel, Field
from mirascope.core import openai, prompttemplate
class TopicAnalysis(BaseModel):
mainthemes: list[str] = Field(description="Main themes")
complexity: str = Field(description="Complexity level: beginner/intermediate/advanced")
prerequisites: list[str] = Field(description="Required prerequisites")
class TutorialPlan(BaseModel):
title: str = Field(description="Tutorial title")
sections: list[str] = Field(description="List of sections")
codeexamplesneeded: int = Field(description="Number of code examples needed")
estimatedduration: str = Field(description="Estimated learning duration")
@openai.call("gpt-4o-mini", responsemodel=TopicAnalysis)
@prompttemplate("Analyze the following topic for tutorial creation: {topic}")
def analyzetopic(topic: str): ...
@openai.call("gpt-4o-mini", responsemodel=TutorialPlan)
@prompttemplate(
"""
Create a tutorial plan based on the following analysis:
Topic: {topic}
Main themes: {themes}
Complexity: {complexity}
Prerequisites: {prerequisites}
"""
)
def plantutorial(
topic: str, themes: str, complexity: str, prerequisites: str
): ...
Chain the calls
topic = "FastAPI for Machine Learning Deployment"
analysis = analyzetopic(topic)
plan = plantutorial(
topic=topic,
themes=", ".join(analysis.mainthemes),
complexity=analysis.complexity,
prerequisites=", ".join(analysis.prerequisites),
)
print(f"Tutorial: {plan.title}")
print(f"Duration: {plan.estimatedduration}")
print(f"Number of sections: {len(plan.sections)}")
print(f"Code examples: {plan.codeexamplesneeded}")
for i, section in enumerate(plan.sections, 1):
print(f" {i}. {section}")
Call Parameters and Configuration
from mirascope.core import openai, prompttemplate
@openai.call(
"gpt-4o-mini",
callparams={
"temperature": 0.7,
"maxtokens": 1000,
"topp": 0.9,
},
)
@prompttemplate("Give me {count} creative ideas for: {topic}")
def brainstorm(topic: str, count: int = 5): ...
response = brainstorm("AI applications for small businesses", count=5)
print(response.content)
Error Handling and Retry
from tenacity import retry, stopafterattempt, waitexponential
from mirascope.core import openai, prompt
template
@retry(
stop=stopafterattempt(3),
wait=waitexponential(multiplier=1, min=2, max=10),
)
@openai.call("gpt-4o-mini")
@prompttemplate("Summarize the following text in 2 sentences: {text}")
def summarizewithretry(text: str): ...
try:
result = summarizewithretry("A long text to summarize...")
print(result.content)
except Exception as e:
print(f"Failed after 3 attempts: {e}")
Async Support
Mirascope supports async calls for better performance:
import asyncio
from mirascope.core import openai, prompttemplate
@openai.call("gpt-4o-mini")
@prompttemplate("Give me an interesting fact about: {topic}")
async def getfact(topic: str): ...
async def main():
topics = ["Python", "Machine Learning", "Space", "Coffee", "Music"]
tasks = [getfact(topic) for topic in topics]
results = await asyncio.gather(tasks)
for topic, result in zip(topics, results):
print(f"\n{topic}: {result.content[:150]}...")
asyncio.run(main())
Custom Response Model with Enum
from enum import Enum
from pydantic import BaseModel, Field
from mirascope.core import openai, prompttemplate
class Priority(str, Enum):
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
CRITICAL = "critical"
class Category(str, Enum):
BUG = "bug"
FEATURE = "feature"
IMPROVEMENT = "improvement"
DOCUMENTATION = "documentation"
class TicketClassification(BaseModel):
category: Category = Field(description="Ticket category")
priority: Priority = Field(description="Priority level")
affectedcomponent: str = Field(description="Affected component")
suggestedassigneeteam: str = Field(description="Suggested team")
estimatedefforthours: float = Field(description="Estimated effort in hours")
summary: str = Field(description="One-sentence summary")
@openai.call("gpt-4o-mini", responsemodel=TicketClassification)
@prompttemplate(
"Classify the following support ticket and determine priority "
"and the team that should handle it:\n\n{ticketdescription}"
)
def classifyticket(ticketdescription: str): ...
ticket = """
The login page has been showing error 500 since last night's deployment.
All users are unable to log in to production. We've tried clearing the cache
but the error persists. Error logs show a NullPointerException in AuthService.
"""
classification = classifyticket(ticket)
print(f"Category: {classification.category.value}")
print(f"Priority: {classification.priority.value}")
print(f"Component: {classification.affectedcomponent}")
print(f"Team: {classification.suggestedassigneeteam}")
print(f"Estimate: {classification.estimatedefforthours} hours")
print(f"Summary: {classification.summary}")
Best Practices
1. Use Type Hints Consistently
from pydantic import BaseModel, Field
from mirascope.core import openai, prompttemplate
class Output(BaseModel):
result: str = Field(description="Clear descriptions help the LLM")
confidence: float = Field(ge=0, le=1, description="Score between 0-1")
@openai.call("gpt-4o-mini", responsemodel=Output)
@prompttemplate("{query}")
def process(query: str) -> None: ...
2. Separate Prompt Templates from Logic
ANALYSISPROMPT = """
SYSTEM: You are a senior data analyst with expertise in {domain}.
USER:
Analyze the following data:
{data}
Focus on:
Key trends
Anomalies
Actionable recommendations
"""
@openai.call("gpt-4o", call
params={"temperature": 0.3})
@prompttemplate(ANALYSISPROMPT)
def analyze(domain: str, data: str): ...
3. Use Response Models for Consistent Output
Always use Pydantic models when output needs to be processed further by code. This ensures:
- Automatic validation
- Type safety
- Implicit documentation through field descriptions
- Automatic retry if output doesn't match the schema
4. Leverage Async for Batch Processing
import asyncio
from mirascope.core import openai, prompttemplate
@openai.call("gpt-4o-mini")
@prompttemplate("Translate to French: {text}")
async def translate(text: str): ...
async def batchtranslate(texts: list[str]) -> list[str]:
tasks = [translate(text) for text in texts]
results = await asyncio.gather(tasks)
return [r.content for r in results]
5. Implement Logging and Monitoring
import time
from mirascope.core import openai, prompttemplate
def logcall(func):
def wrapper(args, *kwargs):
start = time.time()
result = func(args, kwargs)
duration = time.time() - start
print(
f"[{func.name}] "
f"Model: {result.model} | "
f"Tokens: {result.inputtokens}+{result.outputtokens} | "
f"Duration: {duration:.2f}s"
)
return result
return wrapper
@logcall
@openai.call("gpt-4o-mini")
@prompttemplate("Answer the question: {question}")
def answer(question: str): ...
6. Good Project Organization
Recommended project structure for Mirascope-based applications:
myproject/
├── prompts/
│ ├── analysis.py # Prompt templates for analysis
│ ├── extraction.py # Prompt templates for extraction
│ └── generation.py # Prompt templates for generation
├── models/
│ ├── schemas.py # Pydantic response models
│ └── tools.py # Tool definitions
├── services/
│ ├── llmservice.py # LLM call functions
│ └── dataservice.py # Data processing
├── config.py # Configuration
└── main.py # Entry point
Comparison with Other Frameworks
| Feature | Mirascope | LangChain | LlamaIndex |
|---------|-----------|-----------|------------|
| Learning Curve | Low | High | Medium |
| Abstraction | Minimal | Heavy | Medium |
| Type Safety | Built-in (Pydantic) | Partial | Partial |
| Package Size | Small | Large | Medium |
| Provider Support | Multi | Multi | Multi |
| Primary Focus | LLM Calls + Extraction | Chains + Agents | RAG + Indexing |
Mirascope is ideal for developers who want full control over LLM interactions without the overhead of a large framework. If you need comprehensive RAG features, LlamaIndex might be more suitable. For complex workflow orchestration, LangChain offers more built-in tools.
Conclusion
Mirascope offers a refreshing approach to LLM application development in Python. With its Pythonic design, multi-provider support, and built-in type safety through Pydantic, this library is well-suited for:
- Rapid prototyping: Go from simple prompts to structured output in just a few lines of code
- Production applications: Type safety and validation ensure consistent output
- Multi-provider setups: Easily switch or compare between LLM providers
- Data extraction pipelines**: The combination of Pydantic models and LLM calls is powerful for AI-based ETL
Start with simple use cases like text extraction or classification, then gradually adopt advanced features like tool calling and chaining as needed. The key to success with Mirascope is leveraging the Python skills you already have, rather than learning unnecessary new abstractions.
Complete documentation and additional examples can be found in the official Mirascope repository. The active GitHub community is also ready to help if you encounter any challenges during implementation.