Skip to main content

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.

CI PyPI Python License: MIT

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:

  1. exact message content (score: +10)
  2. contains message content (score: +5)
  3. regex message content (score: +4)
  4. model specified (score: +2)
  5. tools_present specified (score: +2)
  6. provider specified (score: +1)
  7. 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

stubllm-0.1.2.tar.gz (31.9 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

stubllm-0.1.2-py3-none-any.whl (28.1 kB view details)

Uploaded Python 3

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

Hashes for stubllm-0.1.2.tar.gz
Algorithm Hash digest
SHA256 aff881c1279646f6563729471e78f5967475a9bcca69e6e64b384590b8e7b504
MD5 13654c1ec3eb88b2052af38e167953ae
BLAKE2b-256 8697a7258e728846ad58d964a83d8b07ca8f7cce5fe2c4551759c6b7968ab35e

See more details on using hashes here.

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

Hashes for stubllm-0.1.2-py3-none-any.whl
Algorithm Hash digest
SHA256 482c9bfc9c40aa910424c3b5c7af52206c08e1e95975acd48aced95dbe8e5194
MD5 b2a9b0ba0563b60ed8e4794951c23dde
BLAKE2b-256 bde2ca8cd76fd0f36c33754813cc15860d04fcf8173a48b94bef591795c71a9c

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page