Mirascope Tutorial: A Pythonic Toolkit for Building LLM Applications

# Tutorial Mirascope: Toolkit Pythonic untuk Membangun Aplikasi LLM ## Pendahuluan Mirascope adalah library Python yang menyediakan antarmuka bersih dan Pythonic untuk berinteraksi dengan Large Lang...

By Ruby Abdullah · · tutorial
MirascopeLLMPythonPrompt EngineeringPydantic

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:

  • Pythonic Design: Uses Python decorators and type hints, not a custom DSL
  • Provider Agnostic: One API for multiple LLM providers
  • Type Safety: Automatic validation using Pydantic
  • Minimal Boilerplate: Clean, readable code
  • Composable: Easy to combine with other Python libraries
  • 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
    

    loaddotenv()

    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")

    @prompttemplate("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, prompttemplate

    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

    weatherdata = {

    "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)

    @prompttemplate("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, prompttemplate

    @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", callparams={"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.

    Related Articles

    Complete Instructor Tutorial: Structured Outputs from LLMs with Pydantic

    Tutorial Lengkap Instructor: Output Terstruktur dari LLM dengan Pydantic Halo temen-temen, di tutorial ini aku mau ngaja...

    PydanticAI Tutorial: A Type-Safe Agent Framework for LLM Apps

    Membangun Agen LLM yang Type-Safe dengan PydanticAI PydanticAI adalah framework agen dari tim di balik Pydantic, diranca...

    Instructor: Getting Structured Output from LLMs with Python

    Instructor: Mendapatkan Structured Output dari LLM dengan Python Salah satu tantangan terbesar saat bekerja dengan Large...

    DSPy: Stop Hand-Tuning Prompts, Let the Compiler Optimize Them

    DSPy: Berhenti Ngoprek Prompt Manual, Biarkan Compiler yang Optimasi Halo temen-temen, kali ini aku mau ngenalin satu li...