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.
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-driftexits 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
BaselineStoreandBaseLLMProviderare 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.yamlexamples 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
Release history Release notifications | RSS feed
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 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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
3db4a696392d936b18e35c0e8369f2cc1f2c530069c423886e5b56629f219444
|
|
| MD5 |
215d651aff3e61a6451dbbf54bdf2a5d
|
|
| BLAKE2b-256 |
f829b1d3fa13dfecdd84ce76162fe78537c778192702fed4176c5e4ae9d5075a
|
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
f76bce3e47d8a83f60e3dc2be1ebeeb89f8361cfbfc388a7e530166cbc630b4d
|
|
| MD5 |
11ebdd1b73edda407781a6efc2623438
|
|
| BLAKE2b-256 |
1f28557cee32ebbc10f71a7bc2048091e1d165970e514e8d853406ad12bb0a50
|