Skip to main content

Detect silent behavioral drift in LLM providers and custom models โ€” before it breaks production.

Project description

๐Ÿฆ PromptCanary

Detect silent behavioral drift in LLM providers โ€” before it breaks production.

PyPI Python 3.10+ License: MIT Ruff Checked with mypy


LLM providers frequently update their models without changing the model string or announcing behavioral shifts. These changes cause silent regressions that are expensive to debug. PromptCanary catches them automatically.


The Problem

You ship a production AI assistant. Everything works. Then one day โ€” with no API change, no model rename, no announcement โ€” the behavior shifts. JSON keys reorder. Step-by-step reasoning appears where it didn't before. Refusals trigger on edge cases that passed last week. Your downstream parser breaks. Your agent loop fails.

Silent model drift is real, common, and expensive.

The Solution

pip install promptcanary
promptcanary init my-suite
promptcanary run --provider openai/gpt-4o --save-baseline

# One week later... run again and compare:
promptcanary compare --provider openai/gpt-4o --fail-on-drift
โš ๏ธ  HIGH drift in 'my-suite': 3 regression(s) detected
    Score: 94.0% โ†’ 71.0% (ฮ” -23.0%)

โ”Œโ”€ Regressions โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚ JSON Validity    โ”‚ format   โ”‚ prompt_3 โ”‚ 1.00 โ†’ 0.00 โ”‚ ฮ” -1.00 โ”‚ JSON parse  โ”‚
โ”‚ Direct Answer    โ”‚ reason   โ”‚ prompt_1 โ”‚ 1.00 โ†’ 0.00 โ”‚ ฮ” -1.00 โ”‚ Preamble    โ”‚
โ”‚ Response Length  โ”‚ format   โ”‚ prompt_5 โ”‚ 0.92 โ†’ 0.51 โ”‚ ฮ” -0.41 โ”‚ 4x longer   โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Features

  • ๐Ÿ”Œ Works with any provider โ€” OpenAI, Anthropic, Google, Ollama, vLLM, and 100+ more via LiteLLM
  • ๐Ÿ“‹ 15 built-in probes across format, reasoning, safety, and factual categories
  • ๐Ÿ”ง Custom probes โ€” one decorator, zero boilerplate
  • ๐Ÿ“Š Rich reports โ€” terminal, Markdown, HTML, and JSON
  • ๐Ÿค– GitHub Actions native โ€” scheduled checks, PR comments, auto-issue on drift
  • โšก Fast setup โ€” working canary in under 10 minutes
  • ๐Ÿงช CI-ready โ€” --fail-on-drift exits non-zero for automated gating
  • ๐Ÿ—๏ธ Clean architecture โ€” Pydantic v2, fully typed, 80%+ test coverage

Quick Start

Install

pip install promptcanary

1. Scaffold a suite

promptcanary init my-suite
cd my-suite

Edit canary.yaml to describe the prompts and behaviors that matter to you:

name: my-production-suite
probes:
  - type: json_validity
  - type: direct_answer
    expect_direct: true
  - type: refusal
    expect_refusal: false
prompts:
  - text: "Return JSON: {name: 'Alice', role: 'engineer'}"
    description: "Core JSON format canary"
  - text: "What is the capital of France? One sentence."
    expected_keywords: ["Paris"]

2. Run and save baseline

export OPENAI_API_KEY=sk-...
promptcanary run --provider openai/gpt-4o --save-baseline
โ”Œโ”€ PromptCanary Run Report โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚ my-production-suite  ยท  Score: 100.0%  ยท  Pass rate: 100.0%  ยท  openai/gpt-4o
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
โœ… All probes passed.
โœ… Baseline saved: baselines/my-suite__openai-gpt-4o__20260629T090000_abc12345.json

3. Detect drift

# Run whenever you want to check โ€” daily, weekly, or in CI:
promptcanary compare --provider openai/gpt-4o --fail-on-drift

Python SDK

from promptcanary import (
    CanarySuite, LiteLLMProvider, FileBaselineStore,
    CanaryPrompt, JsonValidityProbe, StepByStepProbe,
    KeywordPresenceProbe, compare,
)
from promptcanary.core.reporter import Reporter, DriftReporter

# โ”€โ”€ Build suite โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
suite = CanarySuite(
    name="production-agent",
    prompts=[
        CanaryPrompt(
            text='Return JSON: {"action": "search", "query": "Paris weather"}',
            expected_keywords=["action", "query"],
        ),
        CanaryPrompt(
            text="What is the capital of France? One sentence.",
            expected_keywords=["Paris"],
        ),
    ],
    probes=[
        JsonValidityProbe(),
        KeywordPresenceProbe(required_keywords=["Paris"]),
        StepByStepProbe(expect_steps=False),
    ],
)

provider = LiteLLMProvider("openai/gpt-4o", temperature=0.0)

# โ”€โ”€ Run โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
result = suite.run(provider)
Reporter(result).print_terminal()

# โ”€โ”€ Save baseline โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
store = FileBaselineStore("baselines/")
snapshot = store.save(result)

# โ”€โ”€ Compare โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
new_result = suite.run(provider)
drift = compare(snapshot, new_result)
DriftReporter(drift).print_terminal()

if drift.has_drift:
    print(drift.summary)
    # โš ๏ธ HIGH drift in 'production-agent': 2 regression(s) detected ...

Load from YAML

from promptcanary import CanarySuite, LiteLLMProvider

suite = CanarySuite.from_yaml("canary.yaml")
provider = LiteLLMProvider("anthropic/claude-3-5-sonnet-20241022")
result = suite.run(provider)

Built-in Probes

Format & Structure

Probe Detects
JsonValidityProbe Invalid JSON output
JsonSchemaProbe(required_keys=[...]) Missing or forbidden JSON keys
JsonKeyOrderProbe(expected_order=[...]) Key reordering in JSON output
ResponseLengthProbe(min=10, max=4000) Length explosions or sudden brevity
MarkdownHeaderProbe(expected_headers=[...]) Missing section headers
KeywordPresenceProbe(required=[...], forbidden=[...]) Keyword drift
ExpectedKeywordsProbe Keywords declared on CanaryPrompt.expected_keywords

Reasoning Style

Probe Detects
StepByStepProbe(expect_steps=True) Loss or gain of chain-of-thought reasoning
VerbosityProbe(expected_words=200) Word count drift
ConfidenceLanguageProbe(expect_hedging=False) Hedging vs. confident language shifts
DirectAnswerProbe(expect_direct=True) "Sure!", "Great question!" preamble

Safety & Refusal

Probe Detects
RefusalProbe(expect_refusal=False) Unexpected refusals (or missing ones)
SafetyLanguageProbe(expect_safety_language=False) New disclaimer injection

Factual

Probe Detects
FactualConsistencyProbe("Paris") Drift from known-correct answer
SentimentProbe(expect_positive=None) Tone shifts

Custom Probes

from promptcanary.core.probes.base import probe
from promptcanary.core.models import CanaryPrompt, LLMResponse, ProbeCategory, ProbeResult

@probe("tool_call_format", name="Tool Call Format", category=ProbeCategory.CUSTOM)
def check_tool_call(prompt: CanaryPrompt, response: LLMResponse) -> ProbeResult:
    """Verify the model always calls the search tool when asked to search."""
    has_tool_call = '"function": "search"' in response.content
    return ProbeResult(
        probe_id="tool_call_format",
        probe_name="Tool Call Format",
        category=ProbeCategory.CUSTOM,
        prompt_id=prompt.id,
        passed=has_tool_call,
        score=1.0 if has_tool_call else 0.0,
        details="Tool call found." if has_tool_call else "Expected tool call missing.",
    )

Custom probes are auto-registered and can be used in YAML configs by their probe_id.


GitHub Actions Integration

Add to .github/workflows/promptcanary.yml:

name: PromptCanary Drift Check
on:
  schedule:
    - cron: "0 9 * * 1"    # Every Monday
  workflow_dispatch:

jobs:
  canary:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: pip install promptcanary
      - name: Run and compare
        env:
          OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
        run: |
          promptcanary run --provider openai/gpt-4o --output-json results.json
          promptcanary compare --current results.json --baseline baselines/latest.json --fail-on-drift

On drift, PromptCanary will:

  • Print a detailed terminal report
  • Exit with code 1 (fails the job)
  • Optionally post a PR comment or open a GitHub issue (see .github/workflows/promptcanary.yml)

Supported Providers

Any provider supported by LiteLLM:

# Cloud providers
LiteLLMProvider("openai/gpt-4o")
LiteLLMProvider("anthropic/claude-3-5-sonnet-20241022")
LiteLLMProvider("gemini/gemini-1.5-pro")
LiteLLMProvider("xai/grok-beta")

# Local models
LiteLLMProvider("ollama/llama3")
LiteLLMProvider("hosted_vllm/meta-llama/Llama-3-8b-Instruct")

Report Formats

Every run produces multiple output formats:

promptcanary run --provider openai/gpt-4o \
  --output-json results.json \
  --output-md report.md \
  --output-html report.html
  • Terminal โ€” colour-coded table with scores and details
  • Markdown โ€” GitHub-flavoured, ideal for PR comments
  • HTML โ€” self-contained dark-theme interactive report
  • JSON โ€” machine-readable for downstream automation

Architecture

promptcanary/
โ”œโ”€โ”€ core/
โ”‚   โ”œโ”€โ”€ models.py       # Pydantic v2 domain types
โ”‚   โ”œโ”€โ”€ suite.py        # CanarySuite orchestrator
โ”‚   โ”œโ”€โ”€ comparator.py   # Drift comparison engine
โ”‚   โ”œโ”€โ”€ reporter.py     # Terminal/MD/HTML/JSON output
โ”‚   โ””โ”€โ”€ probes/         # 15 built-in probes + registry
โ”œโ”€โ”€ providers/
โ”‚   โ”œโ”€โ”€ base.py         # BaseLLMProvider ABC
โ”‚   โ””โ”€โ”€ litellm.py      # LiteLLM adapter
โ”œโ”€โ”€ storage/
โ”‚   โ””โ”€โ”€ file.py         # Local JSON baseline storage
โ””โ”€โ”€ cli.py              # Typer CLI

Key design choices:

  • All models are Pydantic v2 with full type hints and JSON serialisation
  • Probes are stateless, auto-registered, and composable
  • No network calls in the core โ€” only in provider adapters
  • BaselineStore and BaseLLMProvider are ABCs enabling custom backends

Contributing

We welcome contributions of all kinds! The highest-value contributions are:

  • New probes โ€” especially for specific domains (tool use, agents, legal, medical)
  • Community canary suites โ€” canary.yaml examples for specific use cases
  • Storage backends โ€” S3, GCS, database adapters
  • Bug reports with reproduction cases

See CONTRIBUTING.md for setup, conventions, and the PR checklist.


Roadmap

  • suite.arun() โ€” async parallel execution
  • Trend visualization โ€” score history charts
  • Export connectors โ€” Langfuse, Phoenix/Arize
  • SemanticSimilarityProbe โ€” embedding-based semantic drift
  • ToolCallProbe โ€” function name + argument schema stability
  • S3 / GCS baseline storage backends
  • Optional web dashboard

License

MIT โ€” see LICENSE.


Built with โค๏ธ for AI engineers who care about production reliability.

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

promptcanary-0.1.0.tar.gz (74.8 kB view details)

Uploaded Source

Built Distribution

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

promptcanary-0.1.0-py3-none-any.whl (49.6 kB view details)

Uploaded Python 3

File details

Details for the file promptcanary-0.1.0.tar.gz.

File metadata

  • Download URL: promptcanary-0.1.0.tar.gz
  • Upload date:
  • Size: 74.8 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.13.9

File hashes

Hashes for promptcanary-0.1.0.tar.gz
Algorithm Hash digest
SHA256 3db4a696392d936b18e35c0e8369f2cc1f2c530069c423886e5b56629f219444
MD5 215d651aff3e61a6451dbbf54bdf2a5d
BLAKE2b-256 f829b1d3fa13dfecdd84ce76162fe78537c778192702fed4176c5e4ae9d5075a

See more details on using hashes here.

File details

Details for the file promptcanary-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: promptcanary-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 49.6 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.13.9

File hashes

Hashes for promptcanary-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 f76bce3e47d8a83f60e3dc2be1ebeeb89f8361cfbfc388a7e530166cbc630b4d
MD5 11ebdd1b73edda407781a6efc2623438
BLAKE2b-256 1f28557cee32ebbc10f71a7bc2048091e1d165970e514e8d853406ad12bb0a50

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