Large Language Models (LLMs) have fundamentally changed how we build modern software.
But relying on a single AI model for every user request creates serious production risks. API outages happen. Proprietary models can be expensive for simple tasks. And cheaper open-source models might struggle with complex logical reasoning.
When my team and I built an enterprise-grade AI engine for our customer support platform, we relied on a single top-tier model for everything.
Within a month, we faced two massive issues: a widespread API outage completely froze our app, and our monthly API bill rose because we used expensive reasoning models to answer simple FAQs.
To fix this, I built a resilient, multi-model orchestrator. In this guide, you’ll learn how to build an intelligent, multi-tiered AI application using Python that routes prompts dynamically and handles model fallbacks automatically.
Prerequisites and Environment Setup
To follow along with this tutorial, you should have the following setup:
- Basic proficiency with Python and asynchronous programming.
- Python 3.9 or higher installed on your system.
- A code editor such as Visual Studio Code.
- API keys for at least two model providers (for example, OpenAI and Anthropic), or local models running via Ollama.
Package Installation
Open your terminal and install the required dependencies:
pip install openai anthropic python-dotenv pydantic
Local Directory Structure
Organize your project directory like this to keep your code clean:
ai-model-router/
│
├── .env
├── README.md
└── app.py
Environment Configuration
Create a .env file in the root of your project directory and add your credentials:
OPENAI_API_KEY=your_openai_api_key_here
ANTHROPIC_API_KEY=your_anthropic_api_key_here
ENVIRONMENT=development
The Problem with Single-Model Architectures

If you route every query to a flagship model like GPT-4o or Claude 3.5 Sonnet, you’d be overspending on simple tasks. Conversely, if you route everything to a smaller, faster model like GPT-4o-mini or Claude 3.5 Haiku to save money, your system will fail when users submit complex code-generation or analytical tasks.
On top of cost concerns, single-model systems suffer from single points of failure. When an API provider goes down or rate-limits your account, your entire application crashes.
To solve this, you need an orchestration layer that evaluates prompt complexity before invoking an LLM, routes the request to the most cost-effective model, and falls back to a secondary provider if the primary provider fails.
Understanding the Dynamic Model Routing Lifecycle

Here’s how a user request journeys through a dynamic multi-model system:
Complexity analysis: The system inspects the incoming prompt using lightweight metrics to assign a task tier (Simple, Medium, or Complex).
Model routing: The system maps the tier to the appropriate model — for example, lightweight tasks go to Haiku/Mini while heavy reasoning goes to Sonnet/GPT-4o.
Automatic fallback: If the primary provider times out or throws an API error, the system automatically redirects the query to an equivalent fallback model.
Step 1: Implementing Tier 1 – Prompt Complexity & Intent Analysis
First, you need a deterministic, fast way to classify prompts without making an expensive API call just to decide which model to use.
Before spending money on an LLM API call just to figure out what the user wants, we can look at the text directly in code. Think of this step as a smart gatekeeper. By checking simple things like text length, code snippets, or tricky keywords, we can figure out how hard the task is in milliseconds and for free.
Here’s how we set up our classification rules inside app.py:
import re
from enum import Enum
from pydantic import BaseModel
class TaskComplexity(Enum):
SIMPLE = "simple" # FAQs, short summaries, basic translation
MEDIUM = "medium" # Standard text generation, content rewriting
COMPLEX = "complex" # Code writing, math logic, structural analysis
class PromptAnalyzer:
def __init__(self):
# Regex patterns indicative of complex tasks
self.complex_keywords = [
r"\brefactor\b",
r"\bdebug\b",
r"\bwrite code\b",
r"\banalyze\b",
r"\balgorithm\b",
r"\barchitecture\b",
]
def analyze_complexity(self, prompt: str) -> TaskComplexity:
"""
Evaluates input text deterministically to output
a TaskComplexity rating.
"""
normalized = prompt.lower().strip()
word_count = len(normalized.split())
# Check for code blocks or complex request patterns
contains_code = "```" in prompt
has_complex_keyword = any(
re.search(pattern, normalized)
for pattern in self.complex_keywords
)
if contains_code or has_complex_keyword or word_count > 300:
return TaskComplexity.COMPLEX
elif word_count > 80:
return TaskComplexity.MEDIUM
else:
return TaskComplexity.SIMPLE
# Example Usage
if __name__ == "__main__":
analyzer = PromptAnalyzer()
test_prompt = (
"Write a Python script that implements a trie "
"data structure with autocomplete."
)
complexity = analyzer.analyze_complexity(test_prompt)
print(f"Prompt Complexity Tier: {complexity.value}")
Breaking Down the Code Logic for Tier 1
TaskComplexityEnum: Defines explicit categories for incoming requests (SIMPLE,MEDIUM,COMPLEX), giving us type safety across our pipeline.- Keyword Matching: The
PromptAnalyzerclass sets up regex patterns looking for action words likerefactor,debug, oralgorithmthat signal a heavy reasoning task. - Deterministic Rules in
analyze_complexity:- Formatting & Length Check: We clean the string, check for Markdown code blocks (
```), and calculate word counts. - Tier Allocation:
- If the prompt contains code blocks, trigger words, or exceeds 300 words, it immediately escalates to
COMPLEX. - If it is between 80 and 300 words without code keywords, it maps to
MEDIUM. - Anything shorter defaults to
SIMPLE.
- If the prompt contains code blocks, trigger words, or exceeds 300 words, it immediately escalates to
- Formatting & Length Check: We clean the string, check for Markdown code blocks (
Running this snippet with a complex query checks the text, spots “write code,” and outputs:
Prompt Complexity Tier: complex
Step 2: Implementing Tier 2 – Dynamic Model Routing Logic
Now that we can successfully label a prompt as simple, medium, or complex, we need a rulebook to decide which AI model actually handles it.
This layer maps each complexity tier to a primary model and a secondary fallback model. For instance, simple queries route to budget models (gpt-4o-mini), while complex requests route to heavyweights (claude-3-5-sonnet).
Add this configuration to app.py:
class ModelConfig(BaseModel):
provider: str
model_name: str
class ModelRouter:
def __init__(self):
self.routing_table = {
TaskComplexity.SIMPLE: {
"primary": ModelConfig(provider="openai", model_name="gpt-4o-mini"),
"fallback": ModelConfig(provider="anthropic", model_name="claude-3-5-haiku-20241022"),
},
TaskComplexity.MEDIUM: {
"primary": ModelConfig(provider="anthropic", model_name="claude-3-5-haiku-20241022"),
"fallback": ModelConfig(provider="openai", model_name="gpt-4o-mini"),
},
TaskComplexity.COMPLEX: {
"primary": ModelConfig(provider="anthropic", model_name="claude-3-5-sonnet-20241022"),
"fallback": ModelConfig(provider="openai", model_name="gpt-4o"),
},
}
def get_model_config(self, complexity: TaskComplexity) -> dict:
return self.routing_table[complexity]
Breaking Down the Code Logic for Tier 2
ModelConfig: A Pydantic model that holds the provider name and model identifier for a given selection. Using Pydantic ensures that any misconfigured model entry fails fast with a clear validation error rather than silently routing to the wrong provider.ModelRouter: The core routing class. Itsrouting_tabledictionary maps everyTaskComplexitytier to aprimaryandfallbackModelConfig. Theget_model_configmethod returns the correct pair for a given complexity tier, which the execution pipeline uses to decide where to send the request.
Step 3: Implementing Tier 3 – Automatic Fallbacks
Even the best-designed routing logic cannot prevent provider outages or transient API errors. Tier 3 adds a resilience layer that catches failures from the primary model and automatically retries the same prompt against the designated fallback.
import os
import asyncio
import logging
from openai import AsyncOpenAI
import anthropic
from dotenv import load_dotenv
load_dotenv()
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
class ModelExecutor:
def __init__(self):
self.openai_client = AsyncOpenAI(api_key=os.getenv("OPENAI_API_KEY"))
self.anthropic_client = anthropic.Anthropic(api_key=os.getenv("ANTHROPIC_API_KEY"))
async def execute_openai(self, model_name: str, prompt: str) -> str:
response = await self.openai_client.chat.completions.create(
model=model_name,
messages=[{"role": "user", "content": prompt}],
max_tokens=1024,
)
return response.choices[0].message.content
def execute_anthropic(self, model_name: str, prompt: str) -> str:
message = self.anthropic_client.messages.create(
model=model_name,
max_tokens=1024,
messages=[{"role": "user", "content": prompt}],
)
return message.content[0].text
async def execute_with_fallback(
self, primary: ModelConfig, fallback: ModelConfig, prompt: str
) -> dict:
for config in [primary, fallback]:
try:
logger.info(f"Attempting: {config.provider} / {config.model_name}")
if config.provider == "openai":
result = await self.execute_openai(config.model_name, prompt)
else:
result = await asyncio.to_thread(
self.execute_anthropic, config.model_name, prompt
)
return {
"response": result,
"model_used": config.model_name,
"provider": config.provider,
}
except Exception as e:
logger.warning(f"Provider {config.provider} failed: {e}")
return {"error": "All providers failed.", "response": None}
Breaking Down the Code Logic for Tier 3
ModelExecutor: Initialises async clients for both OpenAI and Anthropic on startup, so there is no connection overhead per request.execute_openai: Uses the native async OpenAI client to dispatch chat completion requests without blocking the event loop.execute_anthropic: The Anthropic SDK is synchronous, so we wrap it inasyncio.to_threadto run it in a thread pool and keep the application non-blocking.execute_with_fallback: Iterates through[primary, fallback]in order. On success it returns the response along with metadata identifying which model and provider handled the request. On failure it logs a warning and moves to the next option. If both providers fail, it returns a structured error dictionary instead of raising an unhandled exception.
Combining the Architecture into a Unified Execution Pipeline
Now we assemble Tier 1 (analysis), Tier 2 (routing), and Tier 3 (execution with fallback) into a single entry point.
class AIOrchestrator:
def __init__(self):
self.analyzer = PromptAnalyzer()
self.router = ModelRouter()
self.executor = ModelExecutor()
async def process(self, prompt: str) -> dict:
complexity = self.analyzer.analyze_complexity(prompt)
model_configs = self.router.get_model_config(complexity)
logger.info(f"Complexity: {complexity.value}")
result = await self.executor.execute_with_fallback(
primary=model_configs["primary"],
fallback=model_configs["fallback"],
prompt=prompt,
)
result["complexity"] = complexity.value
return result
async def main():
orchestrator = AIOrchestrator()
test_cases = [
"What is the capital of France?",
"Rewrite this paragraph to sound more professional.",
"Write a Python function to implement a binary search tree with insert and search methods.",
]
for prompt in test_cases:
print(f"\nPrompt: {prompt}")
result = await orchestrator.process(prompt)
if result.get("response"):
print(f"Complexity : {result['complexity']}")
print(f"Model Used : {result['model_used']} ({result['provider']})")
print(f"Response : {result['response'][:200]}...")
else:
print(f"Error: {result.get('error')}")
if __name__ == "__main__":
asyncio.run(main())
Breaking Down the Code Logic
AIOrchestrator: The single public interface. It wires together the three tiers and exposes oneprocessmethod that accepts a raw prompt string.processmethod: Calls the analyzer to score complexity, retrieves the matching model pair from the router, and delegates execution (with automatic fallback) to the executor. It then attaches the complexity label to the result dictionary so callers have full visibility into how the request was handled.mainfunction: Demonstrates the orchestrator against three prompts that deliberately span all three complexity tiers — a simple factual question, a medium rewriting task, and a complex code-generation request — so you can verify end-to-end routing in a single test run.
Lessons Learnt from Dynamic Model Switching in Production
After running this architecture in a live customer support platform, several patterns emerged that are worth noting before you deploy your own version.
Cost savings are real but require tuning. The routing thresholds in the PromptAnalyzer — 80 words for medium, 300 words for complex — are starting points, not universal constants. Monitor your actual traffic distribution and adjust the boundaries to match the vocabulary and query length patterns of your specific user base.
Fallback metadata is invaluable for debugging. Every response object returned by execute_with_fallback includes the model_used and provider fields. Log these alongside latency and token counts. When costs spike or quality drops, this metadata tells you immediately whether the primary or fallback model is responsible.
Async matters at scale. Wrapping the synchronous Anthropic client in asyncio.to_thread is not just a convenience — under concurrent load, a blocking call in the event loop will stall every other in-flight request. Test your concurrency behaviour early.
Keyword lists need domain-specific expansion. The default regex patterns cover generic engineering terms. If your application handles legal, medical, or financial queries, extend complex_keywords with domain-specific trigger words to avoid routing nuanced professional prompts to underpowered models.
Circuit breaking is the next logical step. The current fallback logic retries once. In production, a provider experiencing a sustained outage will add latency to every request as the system waits for the primary to time out before switching. Integrating a circuit breaker that temporarily removes a failing provider from the rotation will significantly improve response times during incidents.
Conclusion
Building a multi-model orchestrator shifts your AI application from a fragile single-provider dependency into a resilient, cost-aware system. The three-tier architecture covered here — deterministic complexity classification, rule-based model routing, and automatic provider fallback — gives you the foundation to handle real-world traffic efficiently while keeping both costs and downtime under control. From here, you can extend the system with circuit breakers, streaming support, observability tooling, or dynamic threshold tuning based on live traffic data.