litellm/tests/e2e/fixture_canonical.py
mateo-berri 125587d286 feat(e2e): canonical content-based match keys for record-and-replay
Replay previously matched interactions by transport verb and path in
recorded order, so a request whose body drifted from the recording
silently replayed the stale response, and reordering two independent
calls broke replay even though both were recorded. Match keys are now
canonical: fixture_canonical.py strips volatile headers and credential
fields, replaces unique markers, generated ids, uuids, and timestamps
with fixed placeholders, sorts object keys, and hashes what remains, so
a key is stable across runs and machines while any real content drift
is a hard ReplayMiss naming the computed key, the closest recorded key
with its file, and a content diff, with no fallthrough to a live call.
Matching is order-independent across distinct keys and FIFO within one
key. Recording now also redacts credential body and form fields (not
just auth headers) so provider keys never land in bundles.

Resolves LIT-5741
2026-08-19 14:33:22 -07:00

150 lines
4.8 KiB
Python

"""Canonical request identity for replay matching (LIT-5741).
Matching a replayed call against the raw recorded request never hits: unique
markers salt prompts, model names, and tags; every run mints fresh virtual
keys; request ids and timestamps differ on every call. Matching on transport
verb + path alone collides: two different requests to the same route silently
swap responses, which passes when it should miss. The canonicalizer strips
exactly the volatile material (volatile headers, credential fields, markers,
generated ids, timestamps) and hashes what remains with sorted object keys, so
identity is content-based and stable across runs and machines.
Every rewrite rule lives in this module, next to the transports that apply it:
a new volatile header, credential field name, or generated-id shape is one
edit here, never a per-suite change.
"""
from __future__ import annotations
import hashlib
import json
import re
from dataclasses import dataclass
from functools import reduce
from typing import Final
from pydantic import JsonValue
from fixture_bundle import RecordedRequest
VOLATILE_HEADER_NAMES: Final[frozenset[str]] = frozenset(
{
"authorization",
"x-litellm-api-key",
"x-api-key",
"x-goog-api-key",
"x-request-id",
"traceparent",
"tracestate",
}
)
SECRET_FIELD_NAMES: Final[frozenset[str]] = frozenset(
{"api_key", "aws_access_key_id", "static_headers", "vertex_credentials"}
)
SECRET_FIELD_SUFFIXES: Final[tuple[str, ...]] = (
"_api_key",
"_secret_key",
"_secret_access_key",
"_session_token",
"_credentials",
"_password",
)
SECRET_PLACEHOLDER: Final = "<secret>"
PLACEHOLDER_RULES: Final[tuple[tuple[re.Pattern[str], str], ...]] = (
(re.compile(r"(?<![0-9a-fA-F])[0-9a-f]{64}(?![0-9a-fA-F])"), "<sha256>"),
(
re.compile(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"),
"<uuid>",
),
(re.compile(r"sk-[A-Za-z0-9_-]{16,}"), "<key>"),
(
re.compile(r"\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?"),
"<timestamp>",
),
(re.compile(r"(?<!\d)\d{4}-\d{2}-\d{2}(?!\d)"), "<date>"),
(
re.compile(r"\b(?:chatcmpl|msgbatch|msg|resp|batch|call|req|ftjob|gen|file)[-_][A-Za-z0-9]{8,}\b"),
"<id>",
),
(re.compile(r"(?<![0-9a-fA-F])[0-9a-f]{12}(?![0-9a-fA-F])"), "<marker>"),
)
def is_secret_field(name: str) -> bool:
lowered: Final = name.lower()
return lowered in SECRET_FIELD_NAMES or lowered.endswith(SECRET_FIELD_SUFFIXES)
def canonical_string(value: str) -> str:
return reduce(lambda acc, rule: rule[0].sub(rule[1], acc), PLACEHOLDER_RULES, value)
def _canonical_flat(fields: dict[str, str]) -> dict[str, JsonValue]:
return {
key: SECRET_PLACEHOLDER if is_secret_field(key) else canonical_string(value)
for key, value in fields.items()
}
def _canonical_value(value: JsonValue) -> JsonValue:
match value:
case str():
return canonical_string(value)
case dict():
return {
key: SECRET_PLACEHOLDER
if is_secret_field(key) and item is not None
else _canonical_value(item)
for key, item in value.items()
}
case list():
return [_canonical_value(item) for item in value]
case _:
return value
@dataclass(frozen=True, slots=True)
class CanonicalRequest:
method: str
path: str
content: str
@property
def key(self) -> str:
digest: Final = hashlib.sha256(
f"{self.method} {self.path}\n{self.content}".encode()
).hexdigest()[:16]
return f"{self.method} {self.path} #{digest}"
def pretty_content(self) -> str:
return json.dumps(json.loads(self.content), indent=2, sort_keys=True)
def canonicalize(request: RecordedRequest) -> CanonicalRequest:
file_identity: Final[JsonValue | None] = (
None
if request.file_name is None and request.file_sha256 is None
else {
"name": None if request.file_name is None else canonical_string(request.file_name),
"sha256": request.file_sha256,
"bytes": request.file_bytes,
}
)
content: Final[dict[str, JsonValue]] = {
"headers": {
name.lower(): canonical_string(value)
for name, value in request.headers.items()
if name.lower() not in VOLATILE_HEADER_NAMES
},
"params": _canonical_flat(request.params),
"body": _canonical_value(request.body),
"form": None if request.form is None else _canonical_flat(request.form),
"file": file_identity,
}
return CanonicalRequest(
method=request.method,
path=canonical_string(request.path),
content=json.dumps(content, sort_keys=True, separators=(",", ":")),
)