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-5.4 --save-baseline
โโ PromptCanary Run Report โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ my-production-suite ยท Score: 100.0% ยท Pass rate: 100.0% ยท openai/gpt-5.4
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ
All probes passed.
โ
Baseline saved: baselines/my-suite__openai-gpt-5.4__20260629T090000_abc12345.json
3. Detect drift
# Run whenever you want to check โ daily, weekly, or in CI:
promptcanary compare --provider openai/gpt-5.4 --fail-on-drift
Prefer a free, local model? Use Ollama instead โ no API key needed:
ollama pull qwen3.6:27b
promptcanary run --provider ollama/qwen3.6:27b --save-baseline
promptcanary compare --provider ollama/qwen3.6:27b --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-5.4", 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 ...
Using Google Gemini instead
provider = LiteLLMProvider("gemini/gemini-3.5-flash", temperature=0.0)
result = suite.run(provider)
Using a free, local model via Ollama (no API key, zero cost)
# 1. Install Ollama and pull a model: `ollama pull qwen3.6:27b`
provider = LiteLLMProvider("ollama/qwen3.6:27b", temperature=0.0)
result = suite.run(provider)
Load from YAML
from promptcanary import CanarySuite, LiteLLMProvider
suite = CanarySuite.from_yaml("canary.yaml")
provider = LiteLLMProvider("anthropic/claude-sonnet-4-6")
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 |
Tool Use (Agent Workflows)
| Probe | Detects |
|---|---|
ToolCallPresenceProbe(expect_tool_call=True) |
Missing or unexpected function calls |
ToolCallNameProbe("search_web", allow_aliases=[...]) |
Wrong function called |
ToolCallArgsProbe(required_args=[...], forbidden_args=[...]) |
Missing/leaked arguments |
ToolCallSchemaProbe(schema={...}) |
Full structural validation (name + args + types) |
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-5.4 --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
PromptCanary works with any provider supported by LiteLLM โ cloud or local, paid or free.
| Provider | Example model string | API key env var | Cost |
|---|---|---|---|
| OpenAI | openai/gpt-5.5 (flagship), openai/gpt-5.4 (balanced), openai/gpt-5.4-mini (fast) |
OPENAI_API_KEY |
Paid |
| Anthropic | anthropic/claude-opus-4-8 (flagship), anthropic/claude-sonnet-4-6 (balanced) |
ANTHROPIC_API_KEY |
Paid |
| Google Gemini | gemini/gemini-3.1-pro (flagship), gemini/gemini-3.5-flash (balanced), gemini/gemini-3.1-flash-lite (fast) |
GEMINI_API_KEY |
Paid |
| xAI | xai/grok-4 |
XAI_API_KEY |
Paid |
| Ollama (local, free) | ollama/qwen3.6:27b, ollama/deepseek-r1:14b, ollama/gpt-oss:20b, ollama/llama3.3:8b |
(none) | Free |
| vLLM (self-hosted) | hosted_vllm/<org>/<model> |
(none) | Free (self-hosted compute) |
Model availability changes quickly. Run
litellm --testor check LiteLLM's provider docs for the latest model strings before relying on any example here.
# Cloud providers
LiteLLMProvider("openai/gpt-5.4")
LiteLLMProvider("anthropic/claude-sonnet-4-6")
LiteLLMProvider("gemini/gemini-3.5-flash")
LiteLLMProvider("xai/grok-4")
# Local models โ zero cost, full privacy, no API key required
LiteLLMProvider("ollama/qwen3.6:27b") # strong general-purpose, Apache 2.0
LiteLLMProvider("ollama/deepseek-r1:14b") # reasoning-focused, MIT licensed
LiteLLMProvider("ollama/gpt-oss:20b") # OpenAI's open-weight release
LiteLLMProvider("hosted_vllm/meta-llama/Llama-3.3-8B-Instruct")
Why test local models too?
Local, open-weight models (via Ollama or vLLM) make excellent zero-cost canaries:
running them hourly costs nothing and catches infrastructure-level regressions
(prompt template bugs, parser issues) independent of any vendor's API changes.
See notebooks/ci_integration.ipynb for a
cost-aware multi-provider scheduling strategy that mixes free local models with
periodic paid-provider checks.
Report Formats
Every run produces multiple output formats:
promptcanary run --provider openai/gpt-5.4 \
--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
Trend Visualization
Track score history, per-probe heatmaps, and drift timelines across multiple runs:
from promptcanary.storage.file import FileBaselineStore
from promptcanary.utils.visualization import plot_score_history, plot_probe_heatmap
store = FileBaselineStore("baselines/")
snapshots = [store.load_from_path(p) for p in sorted(Path("baselines").glob("*.json"))]
# Works everywhere โ zero dependencies, ASCII sparkline in any terminal
plot_score_history(snapshots, mode="ascii")
# Interactive HTML โ requires: pip install promptcanary[viz]
plot_score_history(snapshots, output_path="trend.html")
plot_probe_heatmap(snapshots, output_path="heatmap.html")
See notebooks/analyzing_drift_trends.ipynb
for a full walkthrough of identifying which probe regresses first during gradual drift.
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/ # 19 built-in probes + registry
โโโ providers/
โ โโโ base.py # BaseLLMProvider ABC
โ โโโ litellm.py # LiteLLM adapter
โโโ storage/
โ โโโ file.py # Local JSON baseline storage
โโโ utils/
โ โโโ visualization.py # Trend charts (ASCII + optional Plotly)
โโโ 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
Done in v0.1.x:
-
ToolCallProbefamily โ function name + argument schema stability for agent workflows - Trend visualization โ score history, probe heatmaps, drift timelines (ASCII + Plotly)
- Property-based tests (Hypothesis) for comparator and scoring invariants
- Full CLI test coverage
Planned:
-
suite.arun()โ async parallel execution - Export connectors โ Langfuse, Phoenix/Arize
-
SemanticSimilarityProbeโ embedding-based semantic drift - 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.2.0.tar.gz.
File metadata
- Download URL: promptcanary-0.2.0.tar.gz
- Upload date:
- Size: 140.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.13.9
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
6909e847cb4f8c24defe6a7e5b1ea3117383bc576a1727679495e48c02b79f34
|
|
| MD5 |
1d7f7dace21a07e75897e8a4fe12faab
|
|
| BLAKE2b-256 |
5d1f8e07c5ff8a48213627331a8528d9b6fcfa561a1f27ab198fc7df520d696d
|
File details
Details for the file promptcanary-0.2.0-py3-none-any.whl.
File metadata
- Download URL: promptcanary-0.2.0-py3-none-any.whl
- Upload date:
- Size: 60.9 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 |
6a1458b8053f7c677da797678994cde329fc003ce5f6bcdf10167c3429dec196
|
|
| MD5 |
b29bfcc0ac98c8e493612090b6105f17
|
|
| BLAKE2b-256 |
9beb3fb99373edcca121b9ee8a4527e7e939fdd628f46992baaebc1f22f1ad76
|