litellm/tests/e2e/fixture_canonical.py
mateo-berri f5df60f106 test(e2e): key multipart uploads by structured part identity
Adversarial review of the new multipart keying turned up collisions where
two different provider requests computed the same replay key, which is the
dangerous failure for a replay harness: the second request silently gets the
first one's response instead of missing loudly.

- a part counts as an upload when it has a filename or declares its own
  content type, and the declared content type joins the identity, so two
  uploads of the same bytes under the same field no longer collapse
- the uploaded parts contribute a JSON list of [field, filename, type]
  triples instead of a "field:filename" string, so a separator inside a
  filename can no longer impersonate a field boundary
- repeated field names get a "name[n]" suffix with a literal "[" doubled
  first, so a repeated field and a literally indexed one stay distinct
- a field value that is not UTF-8 is stored as a base64 sha256 digest;
  base64 rather than hex because the canonicalizer rewrites 64-character
  hex runs to <sha256> and folded every binary value onto one key
- a field whose name reads as a credential is stored as <secret>. This
  stays key-preserving because the key is recomputed from the stored
  request rather than saved beside it, so the live request carrying the
  real value still matches its redacted fixture
- the uploaded byte length leaves the key. The canonicalizer absorbs
  timestamp and id drift inside a file, and that drift moves the count,
  so keeping it there made re-records miss

Also stops a lookalike parameter such as "xboundary=" from being read as
the multipart boundary, and gives the OpenAI batch backend model a single
constant instead of three copies of the literal.

BUNDLE_FORMAT_VERSION goes to 3 because all of this moves recorded keys.
A bundle recorded under the old rules now fails naming both versions
instead of missing on every call.
2026-08-21 19:32:02 -07:00

149 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,
}
)
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=(",", ":")),
)