Deterministic mock server for LLM APIs. Test your AI code without spending tokens.
Project description
stubllm
Deterministic mock server for LLM APIs. Test your AI code without spending tokens.
Works with: OpenAI · Anthropic · Google Gemini
30-second quickstart
# 1. Install
pip install stubllm
# 2. Create a fixture
mkdir fixtures
cat > fixtures/chat.yaml << 'EOF'
fixtures:
- name: "greeting"
match:
provider: openai
messages:
- role: user
content:
contains: "hello"
response:
content: "Hello! How can I help you today?"
EOF
# 3. Start the server
stubllm serve --port 8765
# 4. Point your code at it
export OPENAI_BASE_URL=http://localhost:8765/v1/
python your_app.py # no real API calls, no tokens spent
Why stubllm?
| stubllm | Real API | Ollama | |
|---|---|---|---|
| Cost | Free | Paid | Free |
| Speed | <1ms | 1-30s | 5-30s |
| Deterministic | ✅ | ❌ | ❌ |
| Works offline | ✅ | ❌ | ✅ |
| No GPU needed | ✅ | ✅ | ❌ |
| Pytest integration | ✅ | ❌ | ❌ |
| Fixtures / record-replay | ✅ | ❌ | ❌ |
| CI-friendly | ✅ | Slow/expensive | Heavy |
Installation
pip install stubllm
# With pytest support
pip install "stubllm[pytest]"
Fixture format
Fixtures are YAML (or JSON) files that map request patterns to responses.
Basic text response
fixtures:
- name: "greeting"
match:
provider: openai # openai | anthropic | gemini | any
endpoint: /v1/chat/completions # optional
model: "gpt-4o" # optional
messages:
- role: user
content:
contains: "hello" # exact | contains | regex
response:
content: "Hello! How can I help you today?"
usage:
prompt_tokens: 10
completion_tokens: 12
total_tokens: 22
Tool call response
fixtures:
- name: "weather_tool"
match:
provider: openai
messages:
- role: user
content:
contains: "weather"
tools_present: true # only match when tools are provided
response:
tool_calls:
- id: "call_abc123"
type: function
function:
name: "get_weather"
arguments: '{"location": "Amsterdam"}'
Streaming with delay
fixtures:
- name: "slow_story"
match:
messages:
- role: user
content:
contains: "story"
response:
content: "Once upon a time..."
stream_chunk_delay_ms: 50 # simulate realistic streaming speed
Error response
fixtures:
- name: "rate_limit"
match:
messages:
- role: user
content:
contains: "trigger_error"
response:
content: '{"error": {"message": "Rate limit exceeded", "type": "rate_limit_error"}}'
http_status: 429
Content match strategies
# Exact match (highest priority)
content:
exact: "Hello, world!"
# Substring match (case-insensitive)
content:
contains: "weather"
# Regular expression
content:
regex: "tell me.*joke"
Matching priority
Higher specificity = higher priority. When multiple fixtures match, the most specific wins:
exactmessage content (score: +10)containsmessage content (score: +5)regexmessage content (score: +4)modelspecified (score: +2)tools_presentspecified (score: +2)providerspecified (score: +1)- Fallback (no match criteria)
Pytest plugin
Basic setup
# conftest.py — nothing needed, stubllm auto-registers as a pytest plugin
# The `stubllm_server` fixture is available automatically after installing stubllm
# test_my_app.py
import openai
from stubllm.pytest_plugin import use_fixtures
@use_fixtures("fixtures/chat.yaml")
def test_greeting(stubllm_server):
client = openai.OpenAI(
base_url=stubllm_server.openai_url, # includes /v1/ — openai SDK needs this
api_key="test-key"
)
response = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": "hello world"}]
)
assert "Hello" in response.choices[0].message.content
assert stubllm_server.call_count == 1
Assertion helpers
def test_with_assertions(stubllm_server):
# ... make some calls ...
# Assert specific prompt was sent
stubllm_server.assert_called_with_prompt("hello")
# Assert call counts
stubllm_server.assert_called_once()
stubllm_server.assert_called_n_times(3)
stubllm_server.assert_not_called()
# Assert which model was used
stubllm_server.assert_model_was("gpt-4o")
# Assert last call path
stubllm_server.assert_last_call_path("/v1/chat/completions")
# Raw access
assert stubllm_server.call_count == 2
for call in stubllm_server.calls:
print(call["path"], call["body"])
Multiple fixture files
@use_fixtures("fixtures/chat.yaml", "fixtures/tools.yaml")
def test_combined(stubllm_server):
... # both fixture files are active for this test
Multi-provider support
OpenAI
import openai
client = openai.OpenAI(
base_url="http://localhost:8765/v1/", # note: /v1/ required — the OpenAI SDK does not add it
api_key="test-key"
)
response = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": "hello"}]
)
Anthropic
import anthropic
client = anthropic.Anthropic(
base_url="http://localhost:8765", # Anthropic SDK adds /v1/ itself
api_key="test-key"
)
message = client.messages.create(
model="claude-opus-4-6",
max_tokens=1024,
messages=[{"role": "user", "content": "hello"}]
)
Google Gemini
from google import genai # pip install google-genai
client = genai.Client(
api_key="test-key",
http_options={"base_url": "http://localhost:8765"},
)
response = client.models.generate_content(
model="gemini-2.0-flash",
contents="hello",
)
Streaming
All providers support streaming. Fixtures work identically — streaming is controlled by the stream: true parameter in the request, not the fixture.
# OpenAI streaming
stream = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": "hello"}],
stream=True,
)
for chunk in stream:
print(chunk.choices[0].delta.content, end="", flush=True)
Control streaming speed in fixtures:
response:
content: "A long streaming response..."
stream_chunk_delay_ms: 20 # default: 20ms between chunks
Record and replay
Record real API interactions for later replay:
# Start in record mode (proxies to real OpenAI, saves fixtures)
stubllm record \
--target https://api.openai.com \
--fixture-dir ./recorded_fixtures
# Run your app against the recording proxy
OPENAI_BASE_URL=http://localhost:8765/v1/ python your_app.py
# Fixtures are saved to ./recorded_fixtures/
ls recorded_fixtures/
# recorded_hello_world_1706000000.yaml
# recorded_weather_query_1706000001.yaml
Recorded fixtures are sanitized (API keys removed) and can be committed to your repo.
CLI reference
# Start server (auto-loads ./fixtures/ if it exists)
stubllm serve
# Custom port and fixture directory
stubllm serve --port 9000 --fixture-dir ./my-fixtures
# Multiple fixture directories
stubllm serve --fixture-dir ./fixtures/openai --fixture-dir ./fixtures/anthropic
# Individual fixture files
stubllm serve --fixture-file chat.yaml --fixture-file tools.yaml
# Record mode
stubllm record --target https://api.openai.com --fixture-dir ./recorded
# Version
stubllm --version
Structured output (JSON schema)
When response_format: { type: "json_schema" } is set, stubllm validates that the fixture response is valid JSON. If it's not, it wraps the content automatically.
fixtures:
- name: "structured"
match:
provider: openai
response:
content: '{"name": "Alice", "age": 30}' # must be valid JSON
Project structure
stubllm/
├── src/stubllm/
│ ├── fixtures/ # YAML/JSON loading, Pydantic models, matching engine
│ ├── providers/ # OpenAI, Anthropic, Gemini endpoint handlers
│ ├── streaming/ # SSE streaming simulation
│ ├── recorder/ # Record-and-replay proxy
│ ├── pytest_plugin/ # pytest fixtures and @use_fixtures decorator
│ ├── server.py # FastAPI app factory
│ └── cli.py # click CLI
├── tests/ # >80% coverage
└── examples/ # Working examples (basic + advanced)
Contributing
git clone https://github.com/airupt/stubllm
cd stubllm
pip install -e ".[dev]"
pytest tests/ -v
License
MIT
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file stubllm-0.1.2.tar.gz.
File metadata
- Download URL: stubllm-0.1.2.tar.gz
- Upload date:
- Size: 31.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
aff881c1279646f6563729471e78f5967475a9bcca69e6e64b384590b8e7b504
|
|
| MD5 |
13654c1ec3eb88b2052af38e167953ae
|
|
| BLAKE2b-256 |
8697a7258e728846ad58d964a83d8b07ca8f7cce5fe2c4551759c6b7968ab35e
|
File details
Details for the file stubllm-0.1.2-py3-none-any.whl.
File metadata
- Download URL: stubllm-0.1.2-py3-none-any.whl
- Upload date:
- Size: 28.1 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
482c9bfc9c40aa910424c3b5c7af52206c08e1e95975acd48aced95dbe8e5194
|
|
| MD5 |
b2a9b0ba0563b60ed8e4794951c23dde
|
|
| BLAKE2b-256 |
bde2ca8cd76fd0f36c33754813cc15860d04fcf8173a48b94bef591795c71a9c
|