n4nAI

Swapping models mid-conversation with LlamaIndex

Learn to swap models mid-conversation in LlamaIndex with a conversation manager that preserves context, handles provider fallbacks, and routes dynamically across OpenAI, Anthropic, and local models.

n4n Team3 min read631 words

Audio narration

Coming soon — every post will get a voice note here.

Swapping models mid-conversation with LlamaIndex lets you route requests to the best provider for each turn — cheaper models for simple replies, stronger models for reasoning, or local models when privacy matters. This guide builds a production-ready conversation manager that preserves context across swaps, handles provider failures gracefully, and exposes routing controls your application can drive at runtime.

Step 1: Set up the environment and dependencies

Start with a clean virtual environment. You need LlamaIndex core, the OpenAI and Anthropic integrations, and a way to manage environment variables.

python -m venv .venv
source .venv/bin/activate
pip install llama-index llama-index-llms-openai llama-index-llms-anthropic python-dotenv

Create a .env file with your API keys. If you use n4n.ai as a unified gateway, you only need one key and point the OpenAI-compatible client at the gateway endpoint.

# .env
OPENAI_API_KEY=sk-...
ANTHROPIC_API_KEY=sk-ant-...
# Optional: unified gateway
N4N_API_KEY=n4n-...
N4N_BASE_URL=https://api.n4n.ai/v1

Step 2: Configure multiple LLM providers

Define a registry that holds initialized LLM instances. LlamaIndex’s Settings object controls global defaults, but for per-turn swapping you want explicit instances you can pass to the chat engine.

# llm_registry.py
import os
from dataclasses import dataclass
from typing import Dict, Optional
from llama_index.llms.openai import OpenAI
from llama_index.llms.anthropic import Anthropic
from llama_index.core.llms import LLM
from dotenv import load_dotenv

load_dotenv()

@dataclass
class ModelSpec:
    name: str
    provider: str
    model_id: str
    max_tokens: int = 4096
    temperature: float = 0.7

class LLMRegistry:
    def __init__(self):
        self._models: Dict[str, LLM] = {}
        self._specs: Dict[str, ModelSpec] = {}
        self._register_defaults()

    def _register_defaults(self):
        # OpenAI models
        openai_key = os.getenv("OPENAI_API_KEY")
        if openai_key:
            self.register(ModelSpec(
                name="gpt-4o",
                provider="openai",
                model_id="gpt-4o",
                max_tokens=4096,
            ))
            self.register(ModelSpec(
                name="gpt-4o-mini",
                provider="openai",
                model_id="gpt-4o-mini",
                max_tokens=16384,
            ))

        # Anthropic models
        anthropic_key = os.getenv("ANTHROPIC_API_KEY")
        if anthropic_key:
            self.register(ModelSpec(
                name="claude-3-5-sonnet",
                provider="anthropic",
                model_id="claude-3-5-sonnet-20241022",
                max_tokens=8192,
            ))
            self.register(ModelSpec(
                name="claude-3-haiku",
                provider="anthropic",
                model_id="claude-3-haiku-20240307",
                max_tokens=4096,
            ))

        # Unified gateway (n4n.ai) — single key, 240+ models
        n4n_key = os.getenv("N4N_API_KEY")
        n4n_base = os.getenv("N4N_BASE_URL", "https://api.n4n.ai/v1")
        if n4n_key:
            self.register(ModelSpec(
                name="gpt-4o-via-gateway",
                provider="openai",
                model_id="gpt-4o",
                max_tokens=4096,
            ), base_url=n4n_base, api_key=n4n_key)
            self.register(ModelSpec(
                name="claude-3-5-sonnet-via-gateway",
                provider="anthropic",
                model_id="claude-3-5-sonnet-20241022",
                max_tokens=8192,
            ), base_url=n4n_base, api_key=n4n_key)

    def register(self, spec: ModelSpec, base_url: Optional[str] = None, api_key: Optional[str] = None):
        if spec.provider == "openai":
            llm = OpenAI(
                model=spec.model_id,
                api_key=api_key or os.getenv("OPENAI_API_KEY"),
                base_url=base_url,
                max_tokens=spec.max_tokens,
                temperature=spec.temperature,
            )
        elif spec.provider == "anthropic":
            llm = Anthropic(
                model=spec.model_id,
                api_key=api_key or os.getenv("ANTHROPIC_API_KEY"),
                max_tokens=spec.max_tokens,
                temperature=spec.temperature,
            )
        else:
            raise ValueError(f"Unknown provider: {spec.provider}")

        self._models[spec.name] = llm
        self._specs[spec.name] = spec

    def get(self, name: str) -> LLM:
        if name not in self._models:
            raise KeyError(f"Model '{name}' not registered. Available: {list(self._models.keys())}")
        return self._models[name]

    def list_models(self) -> Dict[str, ModelSpec]:
        return self._specs.copy()

Step 3: Build a conversation manager that preserves history

The core challenge when you swap models mid-conversation with LlamaIndex is keeping the message history intact while changing the underlying LLM. LlamaIndex’s ChatEngine holds a reference to an LLM, but you can replace it by creating a new engine with the same memory.

# conversation.py
from typing import List, Optional, AsyncGenerator
from dataclasses import dataclass, field
from llama_index.core.llms import ChatMessage, MessageRole, LLM
from llama_index.core.memory import ChatMemoryBuffer
from llama_index.core.chat_engine import SimpleChatEngine
from llama_index.core.settings import Settings

@dataclass
class ConversationTurn:
    role: MessageRole
    content: str
    model_used: str
    tokens_in: int = 0
    tokens_out: int = 0

@dataclass
class ConversationState:
    history: List[ConversationTurn] = field(default_factory=list)
    current_model: str = "gpt-4o-mini"
    system_prompt: str = "You are a helpful assistant."

    def to_chat_messages(self) -> List[ChatMessage]:
        msgs = [ChatMessage(role=MessageRole.SYSTEM, content=self.system_prompt)]
        for turn in self.history:
            msgs.append(ChatMessage(role=turn.role, content=turn.content))
        return msgs

class ConversationManager:
    def __init__(self, registry: 'LLMRegistry', initial_model: str = "gpt-4o-mini"):
        self.registry = registry
        self.state = ConversationState(current_model=initial_model)
        self._engine: Optional[SimpleChatEngine] = None
        self._build_engine()

    def _build_engine(self):
        llm = self.registry.get(self.state.current_model)
        memory = ChatMemoryBuffer.from_defaults(token_limit=llm.metadata.context_window - 1024)
        # Pre-populate memory with existing history
        for turn in self.state.history:
            memory.put(ChatMessage(role=turn.role, content=turn.content))
        self._engine = SimpleChatEngine.from_defaults(
            llm=llm,
            memory=memory,
            system_prompt=self.state.system_prompt,
        )

    def swap_model(self, new_model_name: str) -> bool:
        """Swap the active model, preserving full conversation history."""
        if new_model_name == self.state.current_model:
            return False
        if new_model_name not in self.registry.list_models():
            raise ValueError(f"Model '{new_model_name}' not available")

        self.state.current_model = new_model_name
        self._build_engine()
        return True

    def chat(self, user_message: str) -> str:
        """Send a message using the current model, record the turn."""
        response = self._engine.chat(user_message)
        # LlamaIndex response object has raw token counts if available
        tokens_in = getattr(response, 'raw', {}).get('usage', {}).get('prompt_tokens', 0)
        tokens_out = getattr(response, 'raw', {}).get('usage', {}).get('completion_tokens', 0)

        self.state.history.append(ConversationTurn(
            role=MessageRole.USER,
            content=user_message,
            model_used=self.state.current_model,
            tokens_in=tokens_in,
        ))
        self.state.history.append(ConversationTurn(
            role=MessageRole.ASSISTANT,
            content=str(response),
            model_used=self.state.current_model,
            tokens_out=tokens_out,
        ))
        return str(response)

    async def achat(self, user_message: str) -> str:
        response = await self._engine.achat(user_message)
        tokens_in = getattr(response, 'raw', {}).get('usage', {}).get('prompt_tokens', 0)
        tokens_out = getattr(response, 'raw', {}).get('usage', {}).get('completion_tokens', 0)

        self.state.history.append(ConversationTurn(
            role=MessageRole.USER,
            content=user_message,
            model_used=self.state.current_model,
            tokens_in=tokens_in,
        ))
        self.state.history.append(ConversationTurn(
            role=MessageRole.ASSISTANT,
            content=str(response),
            model_used=self.state.current_model,
            tokens_out=tokens_out,
        ))
        return str(response)

    def stream_chat(self, user_message: str) -> AsyncGenerator[str, None]:
        """Stream tokens from the current model."""
        streaming_response = self._engine.stream_chat(user_message)
        full_response = ""
        for token in streaming_response.response_gen:
            full_response += token
            yield token

        # Record after stream completes
        tokens_in = getattr(streaming_response, 'raw', {}).get('usage', {}).get('prompt_tokens', 0)
        tokens_out = getattr(streaming_response, 'raw', {}).get('usage', {}).get('completion_tokens', 0)
        self.state.history.append(ConversationTurn(
            role=MessageRole.USER,
            content=user_message,
            model_used=self.state.current_model,
            tokens_in=tokens_in,
        ))
        self.state.history.append(ConversationTurn(
            role=MessageRole.ASSISTANT,
            content=full_response,
            model_used=self.state.current_model,
            tokens_out=tokens_out,
        ))

Step 4: Implement routing logic for automatic model selection

Hardcoding swaps works for demos. Production systems need routing rules — complexity-based, cost-based, or directive-based. Here’s a policy engine that inspects the user message and conversation state to pick a model.

# routing.py
from typing import Optional, Callable
from dataclasses import dataclass
from enum import Enum
import re

class RoutingStrategy(Enum):
    MANUAL = "manual"
    COMPLEXITY = "complexity"
    COST_OPTIMIZED = "cost_optimized"
    DIRECTIVE = "directive"

@dataclass
class RoutingPolicy:
    strategy: RoutingStrategy = RoutingStrategy.COMPLEXITY
    # Model tiers: (model_name, max_complexity_score)
    tiers: list = None

    def __post_init__(self):
        if self.tiers is None:
            self.tiers = [
                ("gpt-4o-mini", 0.3),
                ("claude-3-haiku", 0.5),
                ("gpt-4o", 0.8),
                ("claude-3-5-sonnet", 1.0),
            ]

class ComplexityScorer:
    """Heuristic complexity scoring for routing decisions."""
    CODE_PATTERNS = [
        r'\b(def|class|function|import|from)\b',
        r'[{}[\]();]',
        r'\b(async|await|Promise|async/await)\b',
    ]
    REASONING_PATTERNS = [
        r'\b(analyze|compare|evaluate|synthesize|reason|step by step)\b',
        r'\b(why|how|explain|justify|prove)\b',
    ]

    def score(self, text: str) -> float:
        score = 0.0
        text_lower = text.lower()

        # Length factor
        score += min(len(text) / 2000, 0.3)

        # Code detection
        for pattern in self.CODE_PATTERNS:
            if re.search(pattern, text):
                score += 0.2
                break

        # Reasoning detection
        for pattern in self.REASONING_PATTERNS:
            if re.search(pattern, text_lower):
                score += 0.3
                break

        # Multi-turn context (would need conversation history)
        # Placeholder for context-aware scoring
        return min(score, 1.0)

class ModelRouter:
    def __init__(self, policy: RoutingPolicy, registry: 'LLMRegistry'):
        self.policy = policy
        self.registry = registry
        self.scorer = ComplexityScorer()

    def select_model(self, user_message: str, conversation_state: 'ConversationState',
                     directive: Optional[str] = None) -> str:
        """Return the model name to use for the next turn."""
        # Explicit directive wins (e.g., from UI or API header)
        if directive and directive in self.registry.list_models():
            return directive

        if self.policy.strategy == RoutingStrategy.MANUAL:
            return conversation_state.current_model

        if self.policy.strategy == RoutingStrategy.DIRECTIVE:
            # Check for inline directives like "@model:gpt-4o"
            match = re.search(r'@model:(\w+)', user_message)
            if match and match.group(1) in self.registry.list_models():
                return match.group(1)
            return conversation_state.current_model

        if self.policy.strategy == RoutingStrategy.COMPLEXITY:
            complexity = self.scorer.score(user_message)
            for model_name, max_complexity in self.policy.tiers:
                if complexity <= max_complexity and model_name in self.registry.list_models():
                    return model_name
            return self.policy.tiers[-1][0]

        if self.policy.strategy == RoutingStrategy.COST_OPTIMIZED:
            # Prefer cheapest model that meets minimum capability
            # This requires a cost map — simplified here
            cheap_models = ["gpt-4o-mini", "claude-3-haiku"]
            for m in cheap_models:
                if m in self.registry.list_models():
                    return m
            return conversation_state.current_model

        return conversation_state.current_model

Step 5: Wire it together with a CLI for testing

A minimal CLI demonstrates the full flow: chat, swap models mid-conversation with LlamaIndex, and verify context carries over.

# main.py
import asyncio
import sys
from llm_registry import LLMRegistry
from conversation import ConversationManager
from routing import ModelRouter, RoutingPolicy, RoutingStrategy

async def main():
    registry = LLMRegistry()
    print("Available models:")
    for name, spec in registry.list_models().items():
        print(f"  {name} ({spec.provider}/{spec.model_id})")

    # Start with a cheap model
    manager = ConversationManager(registry, initial_model="gpt-4o-mini")
    router = ModelRouter(RoutingPolicy(strategy=RoutingStrategy.COMPLEXITY), registry)

    print("\nCommands:")
    print("  /swap <model>     - Switch model manually")
    print("  /policy <strategy> - Change routing: manual, complexity, cost, directive")
    print("  /history          - Show conversation history")
    print("  /quit             - Exit")
    print()

    while True:
        try:
            user_input = input(f"[{manager.state.current_model}] You: ").strip()
        except (EOFError, KeyboardInterrupt):
            break

        if not user_input:
            continue

        if user_input.startswith("/swap "):
            model = user_input[6:].strip()
            try:
                manager.swap_model(model)
                print(f"Swapped to {model}")
            except ValueError as e:
                print(f"Error: {e}")
            continue

        if user_input.startswith("/policy "):
            strategy_name = user_input[8:].strip().upper()
            try:
                router.policy.strategy = RoutingStrategy[strategy_name]
                print(f"Routing policy: {strategy_name}")
            except KeyError:
                print(f"Unknown strategy. Options: {[s.name for s in RoutingStrategy]}")
            continue

        if user_input == "/history":
            for turn in manager.state.history:
                print(f"  [{turn.model_used}] {turn.role.value}: {turn.content[:80]}...")
            continue

        if user_input == "/quit":
            break

        # Determine model for this turn
        selected_model = router.select_model(user_input, manager.state)
        if selected_model != manager.state.current_model:
            manager.swap_model(selected_model)
            print(f"[Auto-routed to {selected_model}]")

        # Stream response
        print(f"[{manager.state.current_model}] Assistant: ", end="", flush=True)
        async for token in manager.stream_chat(user_input):
            print(token, end="", flush=True)
        print()

if __name__ == "__main__":
    asyncio.run(main())

Run it:

python main.py

Step 6: Add provider fallback for resilience

When a provider returns 429 or 5xx, you want automatic fallback without losing the conversation. Wrap the chat call with a retry/fallback chain.

# fallback.py
import asyncio
from typing import List, Optional
from llama_index.core.llms import LLM
from llama_index.core.base.llms.types import ChatResponse, ChatResponseGen
from llama_index.core.llms import ChatMessage

class FallbackLLM(LLM):
    """LLM wrapper that tries a primary model, then falls back through a chain."""
    def __init__(self, primary: LLM, fallbacks: List[LLM]):
        self.primary = primary
        self.fallbacks = fallbacks
        self._current = primary

    @property
    def metadata(self):
        return self._current.metadata

    def chat(self, messages: List[ChatMessage], **kwargs) -> ChatResponse:
        return self._execute_with_fallback(lambda m: m.chat(messages, **kwargs))

    async def achat(self, messages: List[ChatMessage], **kwargs) -> ChatResponse:
        return await self._aexecute_with_fallback(lambda m: m.achat(messages, **kwargs))

    def stream_chat(self, messages: List[ChatMessage], **kwargs) -> ChatResponseGen:
        return self._execute_with_fallback(lambda m: m.stream_chat(messages, **kwargs))

    async def astream_chat(self, messages: List[ChatMessage], **kwargs) -> ChatResponseGen:
        return await self._aexecute_with_fallback(lambda m: m.astream_chat(messages, **kwargs))

    def _execute_with_fallback(self, fn):
        last_error = None
        models = [self.primary] + self.fallbacks
        for model in models:
            try:
                self._current = model
                return fn(model)
            except Exception as e:
                last_error = e
                # Check if error is retryable (rate limit, server error)
                if not self._is_retryable(e):
                    raise
                continue
        raise last_error or RuntimeError("All models failed")

    async def _aexecute_with_fallback(self, fn):
        last_error = None
        models = [self.primary] + self.fallbacks
        for model in models:
            try:
                self._current = model
                return await fn(model)
            except Exception as e:
                last_error = e
                if not self._is_retryable(e):
                    raise
                await asyncio.sleep(0.5)  # Brief backoff
                continue
        raise last_error or RuntimeError("All models failed")

    def _is_retryable(self, error: Exception) -> bool:
        error_str = str(error).lower()
        retryable_indicators = [
            "rate limit", "429", "503", "502", "504",
            "timeout", "connection", "unavailable", "overloaded"
        ]
        return any(indicator in error_str for indicator in retryable_indicators)

Integrate it into the registry:

# In llm_registry.py, add to LLMRegistry class:
def get_with_fallback(self, primary_name: str, fallback_names: List[str]) -> FallbackLLM:
    primary = self.get(primary_name)
    fallbacks = [self.get(name) for name in fallback_names if name in self._models]
    return FallbackLLM(primary, fallbacks)

Then in ConversationManager._build_engine, use registry.get_with_fallback("gpt-4o", ["claude-3-5-sonnet", "gpt-4o-mini"]) instead of registry.get().

Step 7: Verify the implementation

Run these checks to confirm swapping works correctly.

7.1 Context preservation test

# test_context.py
import asyncio
from llm_registry import LLMRegistry
from conversation import ConversationManager

async def test_context_preservation():
    registry = LLMRegistry()
    manager = ConversationManager(registry, initial_model="gpt-4o-mini")

    # Turn 1: Establish context
    resp1 = await manager.achat("My name is Alex and I work on distributed systems.")
    print(f"Turn 1: {resp1}")

    # Turn 2: Swap to a different provider
    manager.swap_model("claude-3-haiku")
    resp2 = await manager.achat("What's my name?")
    print(f"Turn 2 (after swap): {resp2}")

    # Turn 3: Swap again, ask for details
    manager.swap_model("gpt-4o")
    resp3 = await manager.achat("What do I work on?")
    print(f"Turn 3 (after second swap): {resp3}")

    # Verify history has all turns with correct model attribution
    assert len(manager.state.history) == 6  # 3 user + 3 assistant
    assert manager.state.history[0].model_used == "gpt-4o-mini"
    assert manager.state.history[2].model_used == "claude-3-haiku"
    assert manager.state.history[4].model_used == "gpt-4o"

    print("\n✓ Context preserved across 3 model swaps")
    print(f"Models used in sequence: {[t.model_used for t in manager.state.history[::2]]}")

asyncio.run(test_context_preservation())

Run it:

python test_context.py

Expected output shows each response answers correctly using context from previous turns, and the history records which model handled each turn.

7.2 Fallback verification

# test_fallback.py
import asyncio
from llm_registry import LLMRegistry
from fallback import FallbackLLM
from llama_index.llms.openai import OpenAI
from llama_index.core.llms import ChatMessage

async def test_fallback_chain():
    registry = LLMRegistry()

    # Create a failing primary (invalid key) and working fallback
    bad_llm = OpenAI(model="gpt-4o", api_key="sk-invalid")
    good_llm = registry.get("gpt-4o-mini")

    fallback_llm = FallbackLLM(bad_llm, [good_llm])

    messages = [ChatMessage(role="user", content="Say 'fallback worked'")]
    response = await fallback_llm.achat(messages)

    assert "fallback worked" in str(response).lower()
    assert fallback_llm._current == good_llm
    print("✓ Fallback chain executed successfully")

asyncio.run(test_fallback_chain())

7.3 Routing policy verification

# test_routing.py
from routing import ModelRouter, RoutingPolicy, RoutingStrategy, ComplexityScorer
from llm_registry import LLMRegistry
from conversation import ConversationState

def test_complexity_routing():
    registry = LLMRegistry()
    policy = RoutingPolicy(strategy=RoutingStrategy.COMPLEXITY)
    router = ModelRouter(policy, registry)
    state = ConversationState(current_model="gpt-4o-mini")

    # Simple query -> cheap model
    simple = "What's 2+2?"
    model = router.select_model(simple, state)
    assert model in ["gpt-4o-mini", "claude-3-haiku"], f"Expected cheap model, got {model}"

    # Complex query -> stronger model
    complex_q = "Analyze the tradeoffs between eventual consistency and strong consistency in distributed databases, then synthesize a recommendation for a financial ledger system."
    model = router.select_model(complex_q, state)
    assert model in ["gpt-4o", "claude-3-5-sonnet"], f"Expected strong model, got {model}"

    print("✓ Complexity routing selects appropriate tier")

def test_directive_routing():
    registry = LLMRegistry()
    policy = RoutingPolicy(strategy=RoutingStrategy.DIRECTIVE)
    router = ModelRouter(policy, registry)
    state = ConversationState(current_model="gpt-4o-mini")

    # Inline directive
    model = router.select_model("Explain quantum computing @model:gpt-4o", state)
    assert model == "gpt-4o"

    print("✓ Directive routing honors @model: tags")

test_complexity_routing()
test_directive_routing()

Step 8: Production considerations

Token budgeting across swaps

Different models have different context windows. When you swap models mid-conversation with LlamaIndex, the ChatMemoryBuffer token limit should respect the new model’s window. The ConversationManager._build_engine method already handles this by recreating the memory with the current model’s context_window.

Observability

Log every swap with metadata:

import logging
import json

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("model_swap")

def log_swap(old_model: str, new_model: str, reason: str, turn: int):
    logger.info(json.dumps({
        "event": "model_swap",
        "from": old_model,
        "to": new_model,
        "reason": reason,  # "manual", "complexity", "fallback", "directive"
        "turn": turn,
        "timestamp": "2024-01-15T10:30:00Z",
    }))

Cost tracking

Accumulate per-model token counts from ConversationTurn records. Multiply by your provider’s per-token rates for real-time cost dashboards.

Streaming with swaps

If a user requests a model swap mid-stream, cancel the current generation, swap the engine, and restart. The ConversationManager preserves history, so the new model sees the full context including the partial assistant response (which you may want to trim or mark as incomplete).

Common pitfalls

Pitfall Symptom Fix
Forgetting to rebuild ChatMemoryBuffer New model hallucinates or loses early context Always call _build_engine on swap
Mixing Settings.llm global with per-engine LLM Global setting leaks into other components Never set Settings.llm; pass LLM explicitly to engines
Token limit mismatch Truncation errors on large context models Use llm.metadata.context_window dynamically
No fallback on rate limits 429 crashes the conversation Wrap with FallbackLLM or catch and retry with next model
Directive injection Users force expensive models via @model: Validate directives against an allowlist per user tier

Next steps

  • Add a cost-aware router that checks real-time pricing from your provider or gateway
  • Implement model-specific system prompts (Claude likes XML tags, GPT prefers markdown)
  • Build a WebSocket server around ConversationManager for real-time UIs
  • Add evaluation hooks to log model quality per task type and auto-tune routing policies

The pattern scales: one conversation object, multiple models, deterministic swaps. Your application decides the policy; the infrastructure executes it.

Tagsllamaindexmulti-providerdynamic-routing

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All one backend, every model: swapping gpt-5, claude, gemini & llama across frameworks posts →