Skip to content

How Tenro works

Tenro simulates LLM provider responses so you can test your agents without making real API calls.

How simulation works

When you call llm.simulate(Provider.OPENAI, ...), Tenro configures a simulated response. During the test, Tenro intercepts supported outbound requests to that provider and returns the simulated response instead of making a real network call.

Compatibility: Interception works when your LLM call is made through a supported provider SDK (see Compatibility). If your stack uses an unsupported transport or custom HTTP client, interception may not apply.

If interception doesn't apply

If you don't see the simulated response:

  1. Ensure you're using a supported provider SDK (see Compatibility)
  2. Use @link_llm with target= to patch the method directly (see Custom agents)

Agentic loop: how tool calls work

When the LLM decides to call a tool, Tenro returns a provider-specific HTTP response that the framework SDK parses normally:

User Prompt → LLM → Tool Call → Tool executes → Tool Result → LLM → Text Response
sequenceDiagram
    autonumber
    participant Test as Your Test
    participant Agent as Agent/SDK
    participant Tenro as TENRO
    participant Tool as Your Tool

    Note over Test,Tool: Setup: tool.simulate(get_weather, result={temp: 72})<br/>llm.simulate(responses=[{tool_calls: [...]}, "It's 72°F"])

    Test->>Agent: agent.run("What's the weather?")
    Agent->>Tenro: HTTP POST to provider
    Tenro-->>Agent: Fake response with tool_calls
    Agent->>Tool: Execute get_weather()
    Tool-->>Agent: simulate_tool result
    Agent->>Tenro: HTTP POST with tool result
    Tenro-->>Agent: Next queued response
    Agent-->>Test: "It's 72°F"

Tenro returns real provider HTTP responses that your framework SDK parses normally. This means zero framework-specific configuration—LangChain, CrewAI, LangGraph, and others work out of the box.

Supported SDKs

Tenro supports provider SDKs built on httpx:

Supported: OpenAI SDK, Anthropic SDK, Google GenAI SDK, Mistral SDK.

Not supported (yet): SDKs using requests, aiohttp, urllib3, gRPC, or WebSockets.

If your SDK isn't supported, use @link_llm with target= to patch the method directly.

No framework patches

Tenro doesn't modify or monkey-patch any framework's LLM handling. It operates at the HTTP level, which means:

  • No compatibility issues when provider SDKs update
  • No special configuration per framework

@link_tool and @link_agent are standard decorators you apply to your own code—they don't patch framework internals.

What you need (and don't need)

Framework users

If you're using LangChain, CrewAI, LangGraph, or similar:

Decorator Need it? Why
@link_tool Yes Decorate your custom tools for simulation and verification
@link_agent Yes Wrap your agent entry point for tracing
@link_llm No Framework handles LLM calls; HTTP interception covers it
from tenro import link_agent, link_tool

@link_tool("search")
def search(query: str) -> list[str]:
    return database.search(query)

@link_agent("ResearchAgent")
def research(topic: str) -> str:
    docs = search(topic)
    # LangChain/CrewAI/etc. handles LLM internally
    chain = prompt | ChatOpenAI()
    return chain.invoke(docs)

Custom agent builders

If you're making raw SDK calls like openai.chat.completions.create():

Decorator Need it? Why
@link_tool Yes Decorate your custom tools
@link_agent Yes Wrap your agent entry point
@link_llm Optional For tracing; creates LLMScope to trace call origins

@link_llm is for tracing, not interception

HTTP interception works without @link_llm. The decorator creates an LLMScope to trace which function made each LLM call - useful for targeted verification and debugging. Recommended if you can add it, but not required.

from tenro import link_agent, link_llm, link_tool, Provider

@link_tool("search")
def search(query: str) -> list[str]:
    return database.search(query)

@link_llm(Provider.OPENAI)  # Optional - for tracking/annotation
def summarize(text: str) -> str:
    return openai.chat.completions.create(...)

@link_agent("Researcher")
def research(topic: str) -> str:
    docs = search(topic)
    return summarize(docs)

Testing works the same way

Regardless of which approach you use, testing looks the same:

from tenro.simulate import llm, tool
from tenro.testing import tenro
@tenro
def test_research():
    # Use function reference (search defined above with @link_tool)
    tool.simulate(search, result=["doc1", "doc2"])
    llm.simulate(Provider.OPENAI, response="Summary")

    research("AI trends")

    tool.verify_many(search, count=1)
    llm.verify(Provider.OPENAI)

With @link_llm, you can use target= to route simulations to a specific function:

from tenro.simulate import llm
# Route simulation to specific @link_llm function (summarize defined above)
llm.simulate(Provider.OPENAI, target=summarize, response="Summary")

This is useful when you have multiple @link_llm functions and want to control which one receives the simulated response, or when HTTP interception doesn't work for your SDK.

Provider auto-detection

Tenro automatically detects which provider you're using when you call llm.simulate(Provider.OPENAI). OpenAI-compatible providers (Mistral, Together, Groq) work automatically when using the OpenAI SDK with a custom base_url.

Current limitations

Streaming (SSE)

Streaming simulation support is coming soon. Currently, llm.simulate() returns complete responses.

OpenAI Realtime API

The Realtime API uses WebSockets, which Tenro doesn't support yet. Testing Realtime agents requires a different approach.

See also