Skip to main content
Run agent tests offline — no API keys, no network, no bill — with ScriptedModel in place of a real provider and allow_model_requests(False) as a hard gate.
ScriptedModel subclasses the real LLM, so everything around the call — system prompt assembly, tool schemas, the tool loop, streaming, async — runs production code; only the provider-facing methods are replaced.

Quick Start

1

Install and gate the suite

Add the gate to conftest.py so nothing in the suite can reach a provider.
2

Script one text reply

3

Script a tool call + follow-up

4

Assert on what the agent sent

Canonical example

How it works

ScriptedModel is a real LLM subclass that replaces only the methods which talk to a provider — nothing else.
  • System prompts are assembled normally, tools are serialised to real schemas, scripted tool calls are dispatched through the agent’s own executor, and results are fed back as real tool messages.
  • Replies are built as genuine litellm.ModelResponse objects, parsed by the same code path a live response would take — so the double cannot drift from reality.
  • When the agent asks for one more reply than the script holds, the double raises ScriptExhausted (usually the script is one reply short — after a tool call the agent comes back for a follow-up answer).
A script entry may be a str (a final answer), a ScriptedModel.tool_call(...), a list of tool calls, a ScriptedReply, or a callable receiving the RecordedRequest:

The request gate

allow_model_requests(False) blocks all six litellm request sites and both OpenAI client properties process-wide. Any attempt to reach a provider raises ModelRequestBlocked, whose message names the offending call site in your code.
Call allow_model_requests(True) to restore normal behaviour, or scope a single block with no_model_requests():
ModelRequestBlocked and ScriptExhausted derive from BaseException, not Exception — on purpose. The agent’s tool loop catches Exception broadly and turns failures into a None answer. An ordinary exception raised from inside the double would reach your test as a mysterious None, hiding the very thing the test needs to be told (“your script is one reply short”, “a real request leaked”). Deriving from BaseException lets these escape the loop — the same reasoning pytest uses for its own outcome exceptions. Do not wrap your agent calls in a bare except Exception that would swallow them.

When to use offline vs. real-API testing

Offline tests assert what the agent does — which tool it calls, in what order, with what arguments. For prompt quality and model behaviour, use Real API Testing.

ScriptedModel configuration

Inspection surface after a run: ScriptedReply, ScriptedToolCall, and RecordedRequest are importable from praisonaiagents.model_harness when you need to build or type them explicitly.

Best Practices

Put allow_model_requests(False) in a session-scoped fixture (or conftest.py) so a forgotten mock cannot leak a real call and quietly bill you.
Assert requests[i] reflects the call you expect — a scripted tool_call("refund", {"order_id": "A1"}) should mirror what your instructions steer the model toward.
Script with strings and ScriptedModel.tool_call(...) rather than inventing raw provider dicts — the helpers build genuine litellm response objects that the production parser understands.
Keep offline tests and RUN_REAL_KEY_TESTS integration tests in separate sessions. allow_model_requests(False) blocks the whole process, so a real-API test in the same run would fail with ModelRequestBlocked.

Real API Testing

Gated integration tests against live providers.

Guardrails

Test guardrail wiring offline with scripted replies.

Tools

How the tool loop the double exercises works.