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:
- Ensure you're using a supported provider SDK (see Compatibility)
- Use
@link_llmwithtarget=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:
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¶
- LLM Providers - Supported providers and capabilities
- Frameworks overview - Choose your framework
- Custom agents - When to use
@link_llm - What is simulation? - Simulation vs patching