Swapping a model behind your API is easy; proving the new one behaves the same is not. Testing output parity across LLM providers is the unglamorous work that prevents silent regressions in production prompts, broken JSON contracts, and dropped tool calls. This guide lays out a repeatable harness you can run in CI before any cutover.
Step 1: Define what parity means for your use case
Parity is not “identical bytes.” For a free-form chatbot, lexical match is meaningless; for a PII extractor emitting JSON, schema validity and field values are everything. Start by writing down the contract.
from dataclasses import dataclass
@dataclass
class ParitySpec:
exact_text_match: bool = False
json_schema: dict | None = None
required_tool_calls: list[str] | None = None
semantic_similarity_min: float = 0.95
If you are testing output parity across LLM providers for a function-calling agent, required_tool_calls is your primary assertion. For a summarizer, semantic_similarity_min with an embedding model is more honest than edit distance. Decide these numbers before you look at results; otherwise you will tune thresholds to whatever the new model happens to do.
Also document non-output behaviors that matter: does the provider strip leading whitespace? Does it return finish_reason “length” on truncation? Those are parity concerns too.
Step 2: Freeze a representative prompt corpus
You cannot test against random prompts. Export a sample of real traffic or curated edge cases into a version-controlled JSONL file. Each record must be deterministic given temperature=0.
{"id": "inv-001", "system": "Extract entities.", "user": "John ate at Joe's on 5th.", "params": {"temperature": 0, "max_tokens": 100}}
{"id": "inv-002", "system": "You are a math tutor.", "user": "Solve 2x+3=11", "params": {"temperature": 0}}
Load it:
import json
def load_corpus(path):
with open(path) as f:
return [json.loads(line) for line in f if line.strip()]
Keep the corpus small but nasty: include empty strings, unicode, nested JSON, and multi-turn contexts if your app uses them. Aim for 50–200 items. Too few and you miss edge cases; too many and the test is slow. Anonymize any PII before committing. Tag each item with the feature it exercises (extraction, reasoning, refusal) so you can break down failures later.
Step 3: Run dual inference with fixed decoding
Set temperature=0 and a fixed seed if the provider supports it. Non-determinism will otherwise poison your diff. Use one OpenAI-compatible client per provider, or a gateway that fronts both.
from openai import OpenAI
# Example: both models behind one OpenAI-compatible gateway
client = OpenAI(base_url="https://gateway.example/v1", api_key="KEY")
# If you route through a gateway such as n4n.ai, you can target 240+ models from one OpenAI-compatible endpoint and rely on its automatic fallback when a provider is rate-limited, keeping your comparison batch from stalling.
def complete(client, model, item):
resp = client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": item["system"]},
{"role": "user", "content": item["user"]},
],
temperature=item["params"].get("temperature", 0),
max_tokens=item["params"].get("max_tokens", 200),
)
return resp.choices[0].message
Run both:
old_msgs = [complete(client, "old-model", i) for i in corpus]
new_msgs = [complete(client, "new-model", i) for i in corpus]
Store raw responses with the corpus id. Never discard the raw text; you will need it for debugging. If a provider returns a 429, back off and retry; do not let one rate limit abort the whole matrix.
Step 4: Normalize outputs before comparison
Raw chat messages contain whitespace, markdown fences, and inconsistent key ordering. Write a normalizer that matches your ParitySpec.
import json, re
def normalize(msg, spec: ParitySpec):
content = msg.content or ""
if spec.json_schema:
# strip code fences if present
content = re.sub(r"^```json|```$", "", content.strip(), flags=re.M)
return json.loads(content)
if spec.required_tool_calls:
# assume tool_calls attached to message
return [(tc.function.name, json.loads(tc.function.arguments))
for tc in msg.tool_calls]
return content.strip()
For tool calls, compare argument dictionaries after json.loads on the arguments string. Provider SDKs differ in how they serialize arguments (string vs dict), so canonicalize early. If your old provider returns null for missing fields and the new one omits the key, treat them as equal in the comparator.
Step 5: Measure divergence
For text, use difflib.SequenceMatcher to get a ratio. For JSON, deep-compare with jsonschema validation plus a field-level diff. For semantic, embed both strings and compute cosine similarity.
import difflib
from numpy import dot, norm
def text_ratio(a, b):
return difflib.SequenceMatcher(None, a, b).ratio()
def cosine(a_vec, b_vec):
return dot(a_vec, b_vec) / (norm(a_vec) * norm(b_vec))
Aggregate per-item scores and overall pass rate:
results = []
for item, o, n in zip(corpus, old_norm, new_norm):
if spec.json_schema:
ok = (o == n)
elif spec.required_tool_calls:
ok = set(o) == set(n)
else:
ok = text_ratio(o, n) >= spec.semantic_similarity_min
results.append((item["id"], ok))
When testing output parity across LLM providers, expect 100% structural match on JSON tasks and >0.95 semantic on prose. If numbers are lower, inspect the failing ids manually. A single reversed name field in extraction is a launch blocker; a rephrased summary sentence is not.
Step 6: Gate the migration in CI
Wrap the above in a pytest suite that exits non-zero on regression. A minimal conftest:
pytest tests/parity_test.py --corpus=corpus.jsonl --old=old-model --new=new-model
Inside the test:
def test_parity():
spec = ParitySpec(json_schema=EXTRACTOR_SCHEMA)
corpus = load_corpus("corpus.jsonl")
# ... run and normalize ...
failures = [r for r in results if not r[1]]
assert not failures, f"Parity failed on {failures}"
Set the threshold deliberately. A 2% drop in semantic similarity on a summarizer may be acceptable; a single missing tool call is not. Encode those rules as code, not as a human checkbox. Run the suite on every model bump and on every prompt template change.
Step 7: Shadow and verify post-cutover
Green CI is necessary but not sufficient. Deploy the new model in shadow mode: send production traffic to both, log the new model’s output, and alert on divergence beyond your threshold for a 24-hour window.
# pseudo: shadow handler
def handle(req):
old = complete(client, OLD, req)
new = complete(client, NEW, req)
log_parity(req.id, old, new)
return old # still serving old
After the shadow period, review the alert dashboard. If error rates are flat and parity breaches are only in the pre-approved categories, flip the primary route. Keep the old model pinned for instant rollback.
How to verify success
Your migration is safe to ship when: (1) the CI job is green on the frozen corpus, (2) you have manually reviewed the top five lowest-scoring items and confirmed they are acceptable, and (3) the shadow run shows no unexpected parity breaches for one day. At that point, cut over with a rollback plan.
If you follow these steps, testing output parity across LLM providers becomes a boring Thursday task instead of a 3 a.m. incident.