n4nAI

LlamaIndex agents with Claude 3.5 Sonnet on n4n.ai

Build a LlamaIndex agent with Claude 3.5 Sonnet using n4n.ai's OpenAI-compatible endpoint — complete with tool use, streaming, and fallback handling.

n4n Team2 min read496 words

Audio narration

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

If you’re building agents with LlamaIndex and want to use Claude 3.5 Sonnet without managing multiple provider accounts, n4n.ai gives you a single OpenAI-compatible endpoint that routes to 240+ models including Anthropic’s latest. This tutorial walks through a complete, runnable example: a research agent that searches the web, fetches pages, and synthesizes answers with citations. You’ll see tool definitions, streaming responses, and how to handle provider fallbacks when something degrades.

Prerequisites

  • Python 3.10+
  • An n4n.ai API key (get one at n4n.ai)
  • Basic familiarity with LlamaIndex concepts (agents, tools, query engines)

Install the dependencies:

pip install llama-index llama-index-llms-openai llama-index-tools-tavily-research httpx

We use the OpenAI-compatible client because n4n.ai speaks that protocol natively. The llama-index-llms-openai package works out of the box — just point it at the n4n.ai base URL.

Configure the LLM client

Create a small configuration module so you can swap models or endpoints without hunting through code.

# config.py
import os
from llama_index.llms.openai import OpenAI

N4N_API_KEY = os.getenv("N4N_API_KEY")
if not N4N_API_KEY:
    raise RuntimeError("Set N4N_API_KEY in your environment")

# n4n.ai OpenAI-compatible endpoint
N4N_BASE_URL = "https://api.n4n.ai/v1"

def get_llm(model: str = "anthropic/claude-3.5-sonnet", **kwargs) -> OpenAI:
    """
    Return an OpenAI-compatible client pointed at n4n.ai.
    
    The model string follows the format: provider/model-name
    n4n.ai normalizes this across all 240+ models.
    """
    return OpenAI(
        model=model,
        api_key=N4N_API_KEY,
        base_url=N4N_BASE_URL,
        temperature=0.1,
        max_tokens=4096,
        **kwargs,
    )

Set your key and test it:

export N4N_API_KEY="your-key-here"
python -c "from config import get_llm; print(get_llm().complete('Say hello in one sentence').text)"

Expected output:

Hello! How can I help you today?

Define the research tools

We’ll give the agent two tools: a web search (via Tavily) and a page fetcher for deeper reads. LlamaIndex’s FunctionTool wraps any Python callable.

# tools.py
import os
import httpx
from typing import List, Dict, Any
from llama_index.core.tools import FunctionTool

TAVILY_API_KEY = os.getenv("TAVILY_API_KEY")
if not TAVILY_API_KEY:
    raise RuntimeError("Set TAVILY_API_KEY for web search")

async def web_search(query: str, max_results: int = 5) -> List[Dict[str, Any]]:
    """
    Search the web via Tavily and return structured results.
    """
    url = "https://api.tavily.com/search"
    payload = {
        "api_key": TAVILY_API_KEY,
        "query": query,
        "max_results": max_results,
        "search_depth": "advanced",
        "include_answer": True,
        "include_raw_content": False,
    }
    async with httpx.AsyncClient(timeout=30.0) as client:
        resp = await client.post(url, json=payload)
        resp.raise_for_status()
        data = resp.json()
    
    results = []
    for item in data.get("results", []):
        results.append({
            "title": item.get("title"),
            "url": item.get("url"),
            "content": item.get("content"),
            "score": item.get("score"),
        })
    return results


async def fetch_page(url: str, max_chars: int = 8000) -> Dict[str, Any]:
    """
    Fetch a single page and return cleaned text.
    """
    async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
        resp = await client.get(url, headers={"User-Agent": "Mozilla/5.0"})
        resp.raise_for_status()
        text = resp.text
    
    # Naive cleanup — replace with readability/trafilatura for production
    from html import unescape
    import re
    text = re.sub(r"<script.*?</script>", "", text, flags=re.DOTALL | re.IGNORECASE)
    text = re.sub(r"<style.*?</style>", "", text, flags=re.DOTALL | re.IGNORECASE)
    text = re.sub(r"<[^>]+>", " ", text)
    text = unescape(text)
    text = re.sub(r"\s+", " ", text).strip()
    
    return {
        "url": url,
        "text": text[:max_chars],
        "truncated": len(text) > max_chars,
    }


search_tool = FunctionTool.from_defaults(
    fn=web_search,
    name="web_search",
    description="Search the web for current information. Returns title, URL, snippet, and relevance score.",
)

fetch_tool = FunctionTool.from_defaults(
    fn=fetch_page,
    name="fetch_page",
    description="Fetch and extract readable text from a specific URL. Use after web_search to get full content.",
)

Build the agent

LlamaIndex’s ReActAgent implements the Reasoning + Acting loop. We’ll configure it with our tools, a system prompt that enforces citation discipline, and streaming for responsive UX.

# agent.py
from llama_index.core.agent import ReActAgent
from llama_index.core.memory import ChatMemoryBuffer
from llama_index.core.callbacks import CallbackManager
from llama_index.core.tools import BaseTool
from config import get_llm
from tools import search_tool, fetch_tool

SYSTEM_PROMPT = """\
You are a research agent. Answer questions by searching the web, fetching relevant pages, and synthesizing findings.

Rules:
1. Always cite sources inline using [title](url) format.
2. Prefer primary sources and recent content (last 12 months).
3. If sources conflict, present both perspectives.
4. Never hallucinate — if you don't know, say so.
5. Keep answers concise but complete.
"""

def build_agent(tools: list[BaseTool] | None = None) -> ReActAgent:
    if tools is None:
        tools = [search_tool, fetch_tool]
    
    llm = get_llm()
    
    memory = ChatMemoryBuffer.from_defaults(token_limit=8000)
    
    agent = ReActAgent.from_tools(
        tools=tools,
        llm=llm,
        memory=memory,
        system_prompt=SYSTEM_PROMPT,
        verbose=True,
        max_iterations=10,
    )
    return agent

Run a streaming query

Streaming lets you show partial output as the agent reasons and calls tools — critical for perceived latency.

# run_agent.py
import asyncio
from agent import build_agent

async def main():
    agent = build_agent()
    
    query = "What are the key differences between Claude 3.5 Sonnet and GPT-4o for coding tasks? Cite benchmarks."
    
    print(f"Query: {query}\n")
    print("--- Streaming response ---\n")
    
    # astream_chat yields tokens as they arrive
    async for chunk in agent.astream_chat(query):
        print(chunk.delta, end="", flush=True)
    
    print("\n\n--- Done ---")

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

Run it:

python run_agent.py

Expected output (truncated for brevity):

Query: What are the key differences between Claude 3.5 Sonnet and GPT-4o for coding tasks? Cite benchmarks.

--- Streaming response ---

Thought: The user wants a comparison of Claude 3.5 Sonnet and GPT-4o for coding, with benchmarks. I need to search for recent benchmarks and analysis.
Action: web_search
Action Input: {"query": "Claude 3.5 Sonnet vs GPT-4o coding benchmarks 2024", "max_results": 5}
Observation: [{"title": "Claude 3.5 Sonnet vs GPT-4o: Coding Benchmark Comparison", "url": "https://example.com/benchmark", "content": "In HumanEval, Claude 3.5 Sonnet scores 92.0% vs GPT-4o 90.2%...", "score": 0.94}, ...]
Thought: Good, I have benchmark data. Let me fetch the primary source for more detail.
Action: fetch_page
Action Input: {"url": "https://example.com/benchmark"}
Observation: {"url": "https://example.com/benchmark", "text": "Full article text with detailed breakdown...", "truncated": false}

Based on recent benchmarks, here are the key differences for coding tasks:

**Benchmark Performance**
- HumanEval: Claude 3.5 Sonnet 92.0% vs GPT-4o 90.2% [Claude 3.5 Sonnet vs GPT-4o: Coding Benchmark Comparison](https://example.com/benchmark)
- MBPP: Claude 3.5 Sonnet 88.5% vs GPT-4o 87.1% [same source]
- LiveCodeBench: GPT-4o edges out slightly on newer problems

**Practical Differences**
- Claude 3.5 Sonnet shows stronger reasoning on multi-step refactoring tasks
- GPT-4o has better instruction following for exact output formats
- Both handle large contexts well (200k+ tokens)

**Verdict**: For pure coding benchmarks, Claude 3.5 Sonnet holds a slight edge. For production workflows requiring strict format adherence, GPT-4o may be preferable.

--- Done ---

Handle provider fallbacks gracefully

n4n.ai automatically routes around degraded providers, but you should still handle transient errors in your code. Wrap the agent call with a retry policy that respects Retry-After headers.

# resilience.py
import asyncio
import httpx
from tenacity import (
    retry,
    stop_after_attempt,
    wait_exponential_jitter,
    retry_if_exception_type,
)

# Exception types that warrant a retry
RETRYABLE_EXCEPTIONS = (
    httpx.TimeoutException,
    httpx.NetworkError,
    httpx.HTTPStatusError,
)

def is_retryable_error(exc: BaseException) -> bool:
    if isinstance(exc, httpx.HTTPStatusError):
        # Retry on 429, 5xx, but not 4xx (except 429)
        return exc.response.status_code >= 500 or exc.response.status_code == 429
    return isinstance(exc, RETRYABLE_EXCEPTIONS)


@retry(
    wait=wait_exponential_jitter(initial=1, max=30),
    stop=stop_after_attempt(3),
    retry=retry_if_exception_type(RETRYABLE_EXCEPTIONS),
    reraise=True,
)
async def resilient_agent_chat(agent, query: str) -> str:
    """
    Call the agent with automatic retry on transient failures.
    """
    response = await agent.achat(query)
    return str(response)


async def main_with_fallback():
    from agent import build_agent
    agent = build_agent()
    
    query = "Summarize the latest developments in AI agent frameworks as of Q1 2025."
    
    try:
        result = await resilient_agent_chat(agent, query)
        print(result)
    except httpx.HTTPStatusError as e:
        if e.response.status_code == 429:
            retry_after = e.response.headers.get("Retry-After", "60")
            print(f"Rate limited. Retry after {retry_after}s")
        else:
            print(f"HTTP error: {e}")
    except Exception as e:
        print(f"Unexpected error: {e}")

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

The tenacity configuration uses exponential backoff with jitter — standard practice for LLM APIs. n4n.ai forwards provider Retry-After headers, so you can honor them precisely.

Add structured output for downstream consumers

If another service consumes your agent’s output, define a Pydantic schema and use LlamaIndex’s StructuredOutputParser.

# structured.py
from pydantic import BaseModel, Field
from typing import List, Optional
from llama_index.core.output_parsers import PydanticOutputParser
from llama_index.core.prompts import PromptTemplate
from agent import build_agent

class ResearchFinding(BaseModel):
    claim: str = Field(description="A single factual claim")
    citation: str = Field(description="Inline citation in [title](url) format")
    confidence: float = Field(ge=0.0, le=1.0, description="Confidence 0-1")

class ResearchReport(BaseModel):
    question: str
    summary: str
    findings: List[ResearchFinding]
    gaps: List[str] = Field(default_factory=list, description="What we couldn't verify")

parser = PydanticOutputParser(output_cls=ResearchReport)

FORMAT_PROMPT = PromptTemplate(
    "Answer the user's question and return ONLY valid JSON matching this schema:\n"
    "{schema}\n\n"
    "Question: {query_str}\n"
    "Answer:"
)

async def structured_research(query: str) -> ResearchReport:
    agent = build_agent()
    
    # Inject format instructions into the system prompt temporarily
    format_instructions = parser.get_format_string()
    full_prompt = FORMAT_PROMPT.format(schema=format_instructions, query_str=query)
    
    response = await agent.achat(full_prompt)
    return parser.parse(str(response))


async def main():
    report = await structured_research(
        "What are the top 3 LlamaIndex agent patterns in 2024?"
    )
    
    print(f"Question: {report.question}")
    print(f"Summary: {report.summary}")
    print("\nFindings:")
    for i, f in enumerate(report.findings, 1):
        print(f"  {i}. {f.claim} {f.citation} (confidence: {f.confidence:.0%})")
    if report.gaps:
        print("\nGaps:")
        for g in report.gaps:
            print(f"  - {g}")

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

Expected output:

Question: What are the top 3 LlamaIndex agent patterns in 2024?
Summary: The three dominant patterns are ReAct agents with tool use, multi-agent orchestration, and RAG-augmented agents with citation enforcement.

Findings:
  1. ReAct-style agents remain the most widely deployed pattern for single-agent tool use [LlamaIndex 2024 State of Agents](https://example.com/state) (confidence: 90%)
  2. Multi-agent frameworks like AgentWorkflow gained traction for complex workflows [LlamaIndex Blog: Multi-Agent Patterns](https://example.com/multi) (confidence: 85%)
  3. RAG-augmented agents with inline citations became standard for research tasks [LangChain vs LlamaIndex Benchmark](https://example.com/bench) (confidence: 80%)

Gaps:
  - Limited public benchmark data for production-scale deployments

Observability: log tool calls and token usage

Production agents need visibility. LlamaIndex’s callback system captures everything.

# observability.py
from llama_index.core.callbacks import CallbackManager, LlamaDebugHandler
from llama_index.core.global_handler import set_global_handler
from agent import build_agent

# Enable debug handler — captures every LLM call, tool call, and token count
debug_handler = LlamaDebugHandler(print_trace_on_end=True)
callback_manager = CallbackManager([debug_handler])
set_global_handler(callback_manager)

async def main():
    agent = build_agent()
    
    await agent.achat("What is the current status of Python 3.13 adoption?")
    
    # After the call, inspect captured events
    for event in debug_handler.get_llm_events():
        print(f"LLM: {event.model_name} | "
              f"prompt_tokens: {event.prompt_tokens} | "
              f"completion_tokens: {event.completion_tokens} | "
              f"total: {event.total_tokens}")
    
    for event in debug_handler.get_tool_events():
        print(f"TOOL: {event.tool_name} | "
              f"input: {event.tool_input} | "
              f"output_len: {len(str(event.tool_output))}")

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

Sample output:

LLM: anthropic/claude-3.5-sonnet | prompt_tokens: 1,247 | completion_tokens: 312 | total: 1,559
LLM: anthropic/claude-3.5-sonnet | prompt_tokens: 2,103 | completion_tokens: 187 | total: 2,290
TOOL: web_search | input: {'query': 'Python 3.13 adoption rate 2024', 'max_results': 5} | output_len: 1,842
TOOL: fetch_page | input: {'url': 'https://docs.python.org/3/whatsnew/3.13.html'} | output_len: 8,192

This data feeds directly into cost tracking and latency dashboards. n4n.ai meters per-token usage on their side too, so you can reconcile.

Swap models without code changes

Because n4n.ai normalizes the model identifier, switching to a different model — say, google/gemini-1.5-pro or openai/gpt-4o — is a one-line config change. No SDK swap, no prompt rewrites.

# config.py (addition)
def get_llm(model: str = "anthropic/claude-3.5-sonnet", **kwargs) -> OpenAI:
    # Override via env for A/B testing or fallback
    import os
    model = os.getenv("N4N_MODEL", model)
    return OpenAI(
        model=model,
        api_key=N4N_API_KEY,
        base_url=N4N_BASE_URL,
        temperature=0.1,
        max_tokens=4096,
        **kwargs,
    )
N4N_MODEL=openai/gpt-4o python run_agent.py

The agent logic, tools, and output parsing stay identical. This is the practical benefit of a unified gateway: your application code remains provider-agnostic.

What to tackle next

  • Persistent memory: Swap ChatMemoryBuffer for a vector-backed memory store to retain context across sessions.
  • Human-in-the-loop: Add a human_input tool that pauses execution for approval before sensitive actions.
  • Evaluation: Build a golden dataset of questions and use LlamaIndex’s AgentEvaluator to regression-test prompt changes.
  • Deployment: Containerize with a lightweight ASGI server (FastAPI + uvicorn) and put the agent behind an API gateway.

The pattern scales: same tools, same agent loop, same observability — just swap the model identifier when benchmarks or costs shift.

Tagsllamaindexagentsclauden4n-ai

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 llamaindex agents & tool use posts →