test(ocr): add recorded fixture parity harness atop Rust bridge stack

This commit is contained in:
Yujong Lee 2026-09-02 11:36:04 -07:00
parent 3e3d3ce329
commit 112611c9aa
91 changed files with 10102 additions and 46 deletions

View file

@ -106,6 +106,7 @@ jobs:
tests/test_litellm/endpoints
tests/test_litellm/experimental_mcp_client
tests/test_litellm/models
tests/route_parity
tests/test_litellm/repositories
tests/test_litellm/images
tests/test_litellm/interactions

View file

@ -25,7 +25,7 @@ rand = "0.8"
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls", "http2", "stream"] }
rstest = "0.26.1"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
serde_json = { version = "1.0", features = ["float_roundtrip"] }
sha2 = "0.10"
subtle = "2"
thiserror = "2.0"

View file

@ -14,7 +14,7 @@ const AZURE_DOCUMENT_INTELLIGENCE_ENDPOINT_ENV: &str = "AZURE_DOCUMENT_INTELLIGE
const AZURE_DOCUMENT_INTELLIGENCE_API_VERSION: &str = "2024-11-30";
const AZURE_DOCUMENT_INTELLIGENCE_DEFAULT_DPI: i64 = 96;
const AZURE_DOCUMENT_INTELLIGENCE_SUPPORTED_OCR_PARAMS: &[&str] = &["pages"];
const AZURE_DOCUMENT_INTELLIGENCE_SUPPORTED_OCR_PARAMS: &[&str] = &["pages", "features"];
pub struct AzureAiOcrConfig;
pub struct AzureDocumentIntelligenceOcrConfig;
@ -192,6 +192,46 @@ fn normalize_pages_param(pages: &Value) -> Result<Option<String>, Error> {
}
}
fn feature_token_is_valid(token: &str) -> bool {
let Some((first, rest)) = token.as_bytes().split_first() else {
return false;
};
first.is_ascii_alphabetic() && rest.iter().all(u8::is_ascii_alphanumeric)
}
fn invalid_features_error(features: &Value) -> Error {
Error::InvalidRequest(format!(
"Invalid `features` for Azure Document Intelligence: {features:?}. Expected a list of feature names or a comma-separated string like 'keyValuePairs' or 'keyValuePairs,languages'."
))
}
fn normalize_features_param(features: &Value) -> Result<Option<String>, Error> {
let normalized = match features {
Value::String(value) => value
.split(',')
.map(str::trim)
.collect::<Vec<_>>()
.join(","),
Value::Array(values) if values.is_empty() => return Ok(None),
Value::Array(values) => values
.iter()
.map(Value::as_str)
.collect::<Option<Vec<_>>>()
.ok_or_else(|| invalid_features_error(features))?
.into_iter()
.map(str::trim)
.collect::<Vec<_>>()
.join(","),
_ => return Err(invalid_features_error(features)),
};
if normalized.split(',').all(feature_token_is_valid) {
Ok(Some(normalized))
} else {
Err(invalid_features_error(features))
}
}
pub fn complete_document_intelligence_url(
api_base: Option<&str>,
model: &str,
@ -213,6 +253,13 @@ pub fn complete_document_intelligence_url(
url.push_str(&normalized);
}
if let Some(features) = optional_params.get("features")
&& let Some(normalized) = normalize_features_param(features)?
{
url.push_str("&features=");
url.push_str(&normalized);
}
Ok(url)
}
@ -475,6 +522,103 @@ mod tests {
);
}
#[test]
fn document_intelligence_url_normalizes_features() {
let params = serde_json::Map::from_iter([(
"features".to_string(),
json!("keyValuePairs, languages"),
)]);
let url = complete_document_intelligence_url(
Some("https://example.cognitiveservices.azure.com"),
"prebuilt-layout",
&params,
&|_| None,
)
.expect("url builds");
assert_eq!(
url,
"https://example.cognitiveservices.azure.com/documentintelligence/documentModels/prebuilt-layout:analyze?api-version=2024-11-30&features=keyValuePairs,languages"
);
}
#[test]
fn document_intelligence_url_combines_pages_and_feature_list() {
let params = serde_json::Map::from_iter([
("pages".to_string(), json!([0, 1, 2])),
(
"features".to_string(),
json!([" keyValuePairs ", "languages"]),
),
]);
let url = complete_document_intelligence_url(
Some("https://example.cognitiveservices.azure.com"),
"prebuilt-layout",
&params,
&|_| None,
)
.expect("url builds");
assert_eq!(
url,
"https://example.cognitiveservices.azure.com/documentintelligence/documentModels/prebuilt-layout:analyze?api-version=2024-11-30&pages=1,2,3&features=keyValuePairs,languages"
);
}
#[test]
fn document_intelligence_url_omits_empty_feature_list() {
let params = serde_json::Map::from_iter([("features".to_string(), json!([]))]);
let url = complete_document_intelligence_url(
Some("https://example.cognitiveservices.azure.com"),
"prebuilt-layout",
&params,
&|_| None,
)
.expect("url builds");
assert_eq!(
url,
"https://example.cognitiveservices.azure.com/documentintelligence/documentModels/prebuilt-layout:analyze?api-version=2024-11-30"
);
}
#[test]
fn document_intelligence_url_rejects_invalid_features() {
for features in [
json!("keyValuePairs&pages=9"),
json!(""),
json!(["keyValuePairs", 1]),
json!({"feature": "keyValuePairs"}),
] {
let params = serde_json::Map::from_iter([("features".to_string(), features.clone())]);
let error = complete_document_intelligence_url(
Some("https://example.cognitiveservices.azure.com"),
"prebuilt-layout",
&params,
&|_| None,
)
.expect_err("invalid features must fail");
assert!(
matches!(error, Error::InvalidRequest(message) if message.contains("Invalid `features`")),
"features={features:?}"
);
}
}
#[test]
fn document_intelligence_maps_features() {
let params = Map::from_iter([
("features".to_string(), json!(["keyValuePairs"])),
("unsupported".to_string(), json!(true)),
]);
assert_eq!(
AZURE_DOCUMENT_INTELLIGENCE_OCR_CONFIG.map_ocr_params(&params),
Map::from_iter([("features".to_string(), json!(["keyValuePairs"]))])
);
}
#[test]
fn document_intelligence_request_uses_base64_source_for_data_uri() {
let body = AZURE_DOCUMENT_INTELLIGENCE_OCR_CONFIG

View file

@ -31,6 +31,16 @@ pub(crate) fn core_error_to_pyerr(err: Error) -> PyErr {
}
}
pub(crate) fn ocr_error_to_pyerr(err: Error) -> PyErr {
match err {
Error::MissingField("document_url" | "image_url") => {
PyValueError::new_err("Document URL is required")
}
Error::Http { status, body } => RustUpstreamError::new_err((status, body)),
other => core_error_to_pyerr(other),
}
}
/// Map a core error for a route whose host keeps a Python implementation.
///
/// Only an explicit capability decline permits the host to try Python. Every
@ -61,6 +71,29 @@ pub(crate) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> {
mod tests {
use super::*;
#[test]
fn ocr_errors_preserve_python_validation_and_provider_details() {
Python::initialize();
Python::attach(|py| {
for field in ["document_url", "image_url"] {
let mapped = ocr_error_to_pyerr(Error::MissingField(field));
assert!(mapped.is_instance_of::<PyValueError>(py));
assert_eq!(mapped.value(py).to_string(), "Document URL is required");
}
let mapped = ocr_error_to_pyerr(Error::Http {
status: 429,
body: r#"{"message":"rate limited"}"#.to_string(),
});
assert!(mapped.is_instance_of::<RustUpstreamError>(py));
let args: (u16, String) = mapped
.value(py)
.getattr("args")
.and_then(|args| args.extract())
.expect("OCR failures retain status and unprefixed provider message");
assert_eq!(args, (429, r#"{"message":"rate limited"}"#.to_string()));
});
}
#[test]
fn fallback_routes_distinguish_declines_from_upstream_failures() {
Python::initialize();

View file

@ -5,7 +5,7 @@ use litellm_ai_gateway::io::ocr::{OcrRequest, ocr as run_ocr};
use pyo3::prelude::*;
use serde_json::Value;
use crate::errors::core_error_to_pyerr;
use crate::errors::ocr_error_to_pyerr;
use crate::marshal::{RouteOptions, RouteOptionsInputs, object_or_empty};
fn prepare_ocr(
@ -69,5 +69,5 @@ bridge_route! {
timeout_seconds: Option<f64>,
},
prepare = prepare_ocr,
errors = core_error_to_pyerr,
errors = ocr_error_to_pyerr,
}

View file

@ -53,12 +53,13 @@ class _PreparedOCRRequest:
litellm_logging_obj: LiteLLMLoggingObj
@dataclass
@dataclass(frozen=True, slots=True)
class _PreparedRustOCRCall:
api_key: str | None
api_base: str | None
headers: dict[str, object]
optional_params: dict[str, object]
request_url: str
_RUST_OCR_PROVIDERS: Final = {
@ -210,6 +211,10 @@ def _prepare_ocr_request(
def _rust_ocr_supported(prepared_request: _PreparedOCRRequest) -> bool:
if prepared_request.optional_params.get(OCR_REQUEST_FORMAT_PARAM) == "native":
return False
if prepared_request.extra_headers is not None and any(
not isinstance(value, str) for value in prepared_request.extra_headers.values()
):
return False
return prepared_request.custom_llm_provider in _RUST_OCR_PROVIDERS
@ -300,6 +305,7 @@ def _prepare_rust_ocr_call(
api_base=rust_api_base,
headers=cast(dict[str, object], resolved_headers),
optional_params=rust_optional_params,
request_url=resolved_complete_url,
)
@ -322,6 +328,7 @@ def _run_rust_ocr(
extra_headers=prepared.headers,
optional_params=prepared.optional_params,
timeout=prepared_request.effective_timeout,
request_url=prepared.request_url,
)
if rust_response is None:
return None
@ -347,6 +354,7 @@ async def _run_rust_aocr(
extra_headers=prepared.headers,
optional_params=prepared.optional_params,
timeout=prepared_request.effective_timeout,
request_url=prepared.request_url,
)
if rust_response is None:
return None

View file

@ -23,6 +23,14 @@ rust_ocr_enabled = _configuration.rust_ocr_enabled
use_litellm_rust = _configuration.use_litellm_rust
class _OcrProviderError(Exception):
def __init__(self, status_code: int, message: str, request_url: str | None) -> None:
super().__init__(message)
self.status_code: Final = status_code
request: Final = httpx.Request("POST", request_url) if request_url is not None else None
self.response: Final = httpx.Response(status_code=status_code, request=request)
class RustOcr(Protocol):
def __call__(
self,
@ -84,6 +92,7 @@ def ocr(
extra_headers: dict[str, object] | None,
optional_params: dict[str, object],
timeout: float | httpx.Timeout | None,
request_url: str | None = None,
) -> dict[str, object] | None:
rust_ocr: Final = load_rust_ocr()
native_call: Final = (
@ -105,7 +114,12 @@ def ocr(
fallback=lambda: None,
adapt=identity,
mode=FallbackMode.PYTHON,
context=BridgeErrorContext(route="ocr", provider=custom_llm_provider or "", model=model),
context=BridgeErrorContext(
route="ocr",
provider=custom_llm_provider or "",
model=model,
upstream_error=lambda status, message: _OcrProviderError(status, message, request_url),
),
).value
@ -119,6 +133,7 @@ async def aocr(
extra_headers: dict[str, object] | None,
optional_params: dict[str, object],
timeout: float | httpx.Timeout | None,
request_url: str | None = None,
) -> dict[str, object] | None:
rust_aocr: Final = load_rust_aocr()
native_call: Final = (
@ -141,6 +156,11 @@ async def aocr(
fallback=async_none,
adapt=identity,
mode=FallbackMode.PYTHON,
context=BridgeErrorContext(route="ocr", provider=custom_llm_provider or "", model=model),
context=BridgeErrorContext(
route="ocr",
provider=custom_llm_provider or "",
model=model,
upstream_error=lambda status, message: _OcrProviderError(status, message, request_url),
),
)
).value

View file

@ -55,6 +55,7 @@ class BridgeErrorContext:
route: str
provider: str
model: str
upstream_error: Callable[[int, str], Exception] | None = None
def execution_headers(source: CoreEngine) -> dict[str, str]:
@ -221,11 +222,15 @@ def _raise_upstream(error: BaseException, context: BridgeErrorContext) -> NoRetu
message_value: Final = args[1] if len(args) > 1 else str(error)
status: Final = status_value if isinstance(status_value, int) else 0
message: Final = message_value if isinstance(message_value, str) else str(message_value)
api_error: Final = APIError(
status_code=status or 500,
message=f"litellm rust {context.route}: {message}",
llm_provider=context.provider,
model=context.model,
api_error: Final = (
context.upstream_error(status or 500, message)
if context.upstream_error is not None
else APIError(
status_code=status or 500,
message=f"litellm rust {context.route}: {message}",
llm_provider=context.provider,
model=context.model,
)
)
api_error.headers = execution_headers( # pyright: ignore[reportAttributeAccessIssue] # proxy reads exception headers
CoreEngine.RUST

View file

@ -174,6 +174,8 @@ litellm-proxy = "litellm.proxy.client.cli:cli"
[dependency-groups]
dev = [
"diff-cover==9.7.2",
"hypothesis==6.165.10",
"reportlab==5.0.1",
"basedpyright==1.39.7",
"keyring==25.7.0",
"pytest==9.0.3",

View file

@ -172,6 +172,7 @@ pylint: >=3.3.9 # GPLv2 license
langchain-mcp-adapters: >=0.2.1 # MIT License
langgraph: >=1.0.10 # MIT License
langgraph-prebuilt: >=1.0.8 # MIT License - https://github.com/langchain-ai/langgraph/blob/main/LICENSE
hypothesis: >=6.165.10 # MPL 2.0 license
pytest-rerunfailures: >=15.1 # MPL 2.0 license
pytest-recording: >=0.13.4 # MIT license
expression: >=5.6.0 # MIT License - https://github.com/cognitedata/Expression/blob/main/LICENSE

View file

@ -94,6 +94,11 @@ from fixture_mode import (
current_test_key,
parse_fixture_mode,
)
from tests.provider_record_replay.http import (
dropped_request_headers,
dropped_response_headers,
is_streaming_response,
)
EDGE_MOUNTS: Final[Mapping[str, str]] = MappingProxyType(
{
@ -104,29 +109,6 @@ EDGE_MOUNTS: Final[Mapping[str, str]] = MappingProxyType(
REPLAY_MISS_STATUS: Final = 599
_HOP_BY_HOP_HEADERS: Final[frozenset[str]] = frozenset(
{
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailers",
"transfer-encoding",
"upgrade",
}
)
_REQUEST_DROPPED_HEADERS: Final[frozenset[str]] = _HOP_BY_HOP_HEADERS | {
"host",
"content-length",
"accept-encoding",
}
_RESPONSE_DROPPED_HEADERS: Final[frozenset[str]] = _HOP_BY_HOP_HEADERS | {
"content-encoding",
"content-length",
"set-cookie",
}
_JSON: Final[TypeAdapter[JsonValue]] = TypeAdapter(JsonValue)
@ -566,9 +548,8 @@ def _filtered_response_headers(headers: Mapping[str, str]) -> dict[str, str]:
"""What the edge stores and serves: the provider's headers minus hop-by-hop and
volatile entries. Framing headers are in that set, so a stored header can never
contradict the framing the edge chooses when it serves the response."""
return {
name: value for name, value in headers.items() if name not in _RESPONSE_DROPPED_HEADERS
}
excluded: Final = dropped_response_headers(headers.items())
return {name: value for name, value in headers.items() if name.lower() not in excluded}
def _network_error_response(message: str) -> RecordedHttpResponse:
@ -610,7 +591,7 @@ def _is_streamed(headers: Mapping[str, str]) -> bool:
move nearly every recording to the streamed shape for no gain. The content type
is the header that says "consume this as it arrives", and it is already how the
harness defines streaming everywhere else."""
return "text/event-stream" in _header_value(headers, "content-type").lower()
return is_streaming_response(_header_value(headers, "content-type"))
def _upstream_url(upstream_base: str, upstream_path: str, query: str) -> str:
@ -698,8 +679,9 @@ def _handle_record(
timeout: float,
) -> EdgeOutcome:
test_key: Final = current_test_key()
excluded: Final = dropped_request_headers(headers.items())
forwarded: Final = {
name: value for name, value in headers.items() if name.lower() not in _REQUEST_DROPPED_HEADERS
name: value for name, value in headers.items() if name.lower() not in excluded
}
head: Final = forward_stream(method, url, headers=forwarded, body=body, timeout=timeout)
match head:

View file

@ -0,0 +1 @@

View file

@ -0,0 +1,54 @@
from __future__ import annotations
from collections.abc import Iterable
from typing import Final
HOP_BY_HOP_HEADERS: Final[frozenset[str]] = frozenset(
{
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"trailers",
"transfer-encoding",
"upgrade",
}
)
REQUEST_DROPPED_HEADERS: Final[frozenset[str]] = HOP_BY_HOP_HEADERS | {
"host",
"content-length",
"accept-encoding",
}
RESPONSE_DROPPED_HEADERS: Final[frozenset[str]] = HOP_BY_HOP_HEADERS | {
"content-encoding",
"content-length",
"set-cookie",
}
def connection_header_names(headers: Iterable[tuple[str, str]]) -> frozenset[str]:
return frozenset(
token.strip().lower()
for name, value in headers
if name.lower() == "connection"
for token in value.split(",")
if token.strip()
)
def dropped_request_headers(headers: Iterable[tuple[str, str]]) -> frozenset[str]:
materialized: Final = tuple(headers)
return REQUEST_DROPPED_HEADERS | connection_header_names(materialized)
def dropped_response_headers(headers: Iterable[tuple[str, str]]) -> frozenset[str]:
materialized: Final = tuple(headers)
return RESPONSE_DROPPED_HEADERS | connection_header_names(materialized)
def is_streaming_response(content_type: str) -> bool:
return "text/event-stream" in content_type.lower()

View file

@ -0,0 +1,91 @@
# Implementation parity testing through the SDK interface
> Given the same SDK call and identical provider behavior, do two implementations expose the same SDK contract?
## What the harness compares
- A fixture contains a LiteLLM SDK input and a recorded upstream provider response
- The same LiteLLM input is transformed by isolated baseline and candidate implementations
- The resulting provider requests must match in method, path, headers, and body, excluding runtime-specific HTTP metadata
- The recorded provider response is then replayed unchanged to both workers
- The harness compares the values returned through the Python SDK interface
- Non-streaming responses are compared directly, including their concrete return type and public model fields
- Streaming responses are consumed and compared chunk by chunk, including wrapper type, chunk type and order, termination, and public exception behavior
- Failed SDK calls are compared by exception class, stable message, status, code, model, provider, and parameter fields
- Traceback paths and line numbers are excluded because they are runtime-specific
- Route-specific comparators and chunk normalizers handle differences in each public SDK contract
## Process isolation
- SDK object and stream parity runs both implementations sequentially in the same process so tests can retain returned objects
- Every test saves and restores the original bridge state
- A small subprocess smoke test verifies environment-based startup configuration and detects fallback to the Python HTTP implementation
## Streaming execution
The invocation callback passed to `run_in_process` must consume the stream before returning its `StreamOutcome`.
Use `consume_sync_stream` inside that callback, or await `consume_async_stream` inside the callback passed to
`run_in_process_async`. Provider requests are collected only after the callback completes. Streaming is explicit:
an iterable return value alone does not select stream consumption
The consumers retain the wrapper type, iteration capabilities, chunk types and order, and any partial output before
an error. Errors retain their creation or iteration phase and the full public `SDKError` fields, with traceback text
removed. `capture_sync_stream` and `capture_async_stream` consume through the same helpers and then serialize the
outcome for subprocess reports. A serialization failure raises as a harness failure rather than becoming an SDK error
Response models and stream chunks share a recursive comparator. It compares concrete model, container, and scalar
types, public fields and extras, and exact values while ignoring Pydantic private attributes at every nesting level.
An API may supply an explicit chunk normalizer for its public contract
Shared tests exercise a local SSE provider through recording, VCR cassette storage, replay, and typed event comparison
in sync and async modes. They cover fragmented events, split UTF-8 characters, CRLF framing, coalesced events, and
application errors within a normally completed HTTP stream. HTTP byte boundaries and decoded SDK event boundaries
are checked separately
OCR remains the only integrated LiteLLM route. These tests validate shared streaming machinery, not another route's
SDK parity. Connection interruption, early cancellation, and lifecycle timeout enforcement remain outside this coverage
## Hypothesis and property-based testing
- Hypothesis is a Python library for property-based testing
- Example-based tests use inputs selected by the test author
- Property-based tests define strategies for valid inputs and properties that must hold for every generated example
- Hypothesis generates combinations from those strategies and normally shrinks a failing example to a smaller reproducible case
- In this harness, Hypothesis is used only during fixture generation to expand the LiteLLM input corpus
- Each API owns the strategies that vary its supported inputs
- Fixture generation is deterministic, and each generated input is recorded with the raw provider response it received
- The parity tests use committed fixtures and do not call the provider or generate new Hypothesis examples
- Provider responses are replayed unchanged, so the parity test does not fuzz or validate provider behavior
- Because Hypothesis does not run the parity assertion directly, parity failures are not automatically shrunk
## API-owned fixtures
The shared package owns recording, replay, persistence, execution, comparison, and route-neutral media constructors.
Each API package owns its input models, explicit strategies, provider targets, route-specific assets, fixture directory,
and regeneration command. See the API package documentation for its configured contracts and recording command
## VCR cassettes
Fixtures use VCR's YAML `version: 1` format with ordered request/response `interactions`. VCR handles text and binary
body serialization. Each cassette also contains `recorded_at`, `ttl_seconds: 0` (committed fixtures never expire), and
`x-litellm` metadata holding the SDK input and request provenance. Streaming responses carry
`x-litellm-chunk-lengths` so local replay preserves the original byte boundaries
The recording server captures requests before forwarding their responses. Saved requests use the stable
`http://parity-provider.invalid` origin and strip authentication headers and credential query parameters. The upstream
request keeps its credentials. Provider response bytes and non-success statuses are preserved
Standard VCR can load these files and replay their interactions. Parity tests keep using the local HTTP server because
Rust HTTP calls do not pass through VCR's Python patches. The harness still compares the two implementations' requests
against each other; the saved request is available for inspection and VCR playback, not a new parity assertion
Refresh parity cassettes through the API's recording command. Generic VCR writers do not preserve the SDK metadata
Legacy JSON fixtures remain readable. Migrated cassettes mark reconstructed requests as `python_replay`; fresh
recordings use `recorded`. The metadata extensions follow the filesystem cassette layout proposed in
[PR #39338](https://github.com/BerriAI/litellm/pull/39338), without depending on its unmerged persistence backend
## References
- [Hypothesis documentation](https://hypothesis.readthedocs.io/en/latest/)
- [Hypothesis documentation source](https://github.com/HypothesisWorks/hypothesis/tree/master/hypothesis/docs)

View file

@ -0,0 +1,3 @@
import pytest
pytest.register_assert_rewrite("tests.route_parity.compare")

View file

@ -0,0 +1,80 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from typing import Final, cast
from pydantic import BaseModel
from tests.route_parity.models import CapturedRequest, Execution
def validate_harness(baseline: Execution, candidate: Execution, baseline_user_agent: str) -> None:
for request in baseline.requests:
if request.user_agent != baseline_user_agent:
raise AssertionError(
f"baseline provider request did not carry sentinel user-agent {baseline_user_agent!r}: "
f"{request.user_agent!r}"
)
for request in candidate.requests:
if request.user_agent == baseline_user_agent:
raise AssertionError("candidate route fell back to the baseline HTTP implementation")
def _request_after_transformation(request: CapturedRequest) -> CapturedRequest:
return request.model_copy(update={"user_agent": None})
def assert_request_parity(baseline: tuple[CapturedRequest, ...], candidate: tuple[CapturedRequest, ...]) -> None:
baseline_requests: Final = tuple(_request_after_transformation(request) for request in baseline)
candidate_requests: Final = tuple(_request_after_transformation(request) for request in candidate)
assert_value_parity(baseline_requests, candidate_requests)
def _public_model_values(model: BaseModel) -> dict[str, object]:
fields: Final = (*type(model).model_fields, *type(model).model_computed_fields)
extras: Final = cast(Mapping[str, object], model.model_extra or {})
return {
**{name: cast(object, getattr(model, name)) for name in fields if not name.startswith("_")},
**{name: value for name, value in extras.items() if not name.startswith("_")},
}
def assert_model_parity(baseline: BaseModel, candidate: BaseModel) -> None:
assert_value_parity(baseline, candidate)
def assert_value_parity(baseline: object, candidate: object, *, path: str = "$") -> None:
assert type(baseline) is type(candidate), f"type mismatch at {path}: {type(baseline)} != {type(candidate)}"
if isinstance(baseline, BaseModel) and isinstance(candidate, BaseModel):
assert_value_parity(_public_model_values(baseline), _public_model_values(candidate), path=path)
return
if isinstance(baseline, Mapping) and isinstance(candidate, Mapping):
baseline_mapping: Final = cast(Mapping[object, object], baseline)
candidate_mapping: Final = cast(Mapping[object, object], candidate)
assert frozenset((type(key), key) for key in baseline_mapping) == frozenset(
(type(key), key) for key in candidate_mapping
), f"mapping keys differ at {path}"
for key in baseline_mapping:
assert_value_parity(baseline_mapping[key], candidate_mapping[key], path=f"{path}.{key}")
return
if (
isinstance(baseline, Sequence)
and not isinstance(baseline, (str, bytes))
and isinstance(candidate, Sequence)
and not isinstance(candidate, (str, bytes))
):
baseline_sequence: Final = cast(Sequence[object], baseline)
candidate_sequence: Final = cast(Sequence[object], candidate)
assert len(baseline_sequence) == len(candidate_sequence), f"sequence lengths differ at {path}"
for index, (baseline_item, candidate_item) in enumerate(
zip(baseline_sequence, candidate_sequence, strict=True)
):
assert_value_parity(baseline_item, candidate_item, path=f"{path}[{index}]")
return
assert baseline == candidate, f"value mismatch at {path}: {baseline!r} != {candidate!r}"
def assert_parity(baseline: Execution, candidate: Execution, baseline_user_agent: str) -> None:
validate_harness(baseline, candidate, baseline_user_agent)
assert_request_parity(baseline.requests, candidate.requests)
assert_value_parity(baseline.report, candidate.report)

View file

@ -0,0 +1,64 @@
from __future__ import annotations
from collections.abc import Mapping
from typing import ClassVar, Final, Generic, Literal, TypeVar, cast
from pydantic import BaseModel, ConfigDict, Field, JsonValue, model_validator
from tests.route_parity.recorded_http import RecordedResponse
JsonObject = dict[str, JsonValue]
class FixtureModel(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid", populate_by_name=True, serialize_by_alias=True)
class SdkInputBase(FixtureModel):
fixture_only_fields: ClassVar[tuple[str, ...]] = ()
def as_sdk_kwargs(self) -> dict[str, object]:
return cast(
dict[str, object],
self.model_dump(
mode="python",
exclude_unset=True,
exclude=set(self.fixture_only_fields),
),
)
def canonical_input(self) -> dict[str, object]:
dumped: Final = cast(dict[str, object], self.model_dump(mode="json", exclude_unset=True))
fixture_fields: Final = {field: getattr(self, field) for field in self.fixture_only_fields}
return {**fixture_fields, **dumped}
class JsonSchemaDefinition(FixtureModel):
name: str
description: str | None = None
schema_definition: JsonObject = Field(alias="schema")
strict: bool = False
class JsonSchemaResponseFormat(FixtureModel):
type: Literal["json_schema"]
json_schema: JsonSchemaDefinition
InputT = TypeVar("InputT", bound=SdkInputBase)
class ParityCase(FixtureModel, Generic[InputT]):
litellm_input: InputT
provider_responses: tuple[RecordedResponse, ...]
@model_validator(mode="before")
@classmethod
def load_legacy_single_response(cls, value: object) -> object:
if not isinstance(value, Mapping):
return value
migrated: Final = dict(cast(Mapping[str, object], value))
provider_response: Final = migrated.pop("provider_response", None)
if "provider_responses" not in migrated and provider_response is not None:
migrated["provider_responses"] = (provider_response,)
return migrated

View file

@ -0,0 +1 @@
from __future__ import annotations

View file

@ -0,0 +1,147 @@
from __future__ import annotations
from collections.abc import Mapping
from datetime import datetime
from itertools import accumulate
from typing import Final, Literal
from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, TypeAdapter
from vcr.serialize import serialize
from vcr.serializers import yamlserializer
from tests.route_parity.fixtures.recording import RecordedInteraction
from tests.route_parity.recorded_http import (
HttpHeader,
RecordedHttpResponse,
RecordedHttpStreamResponse,
RecordedResponse,
RecordedStreamChunk,
)
_OBJECT: Final = TypeAdapter(dict[str, object])
class _CassetteModel(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid", populate_by_name=True)
class _Body(_CassetteModel):
string: str | bytes
def as_bytes(self) -> bytes:
return self.string.encode("utf-8") if isinstance(self.string, str) else self.string
class _Status(_CassetteModel):
code: int
message: str
class _Request(_CassetteModel):
method: str
uri: str
body: str | bytes | None
headers: dict[str, tuple[str, ...]]
class _Response(_CassetteModel):
status: _Status
headers: dict[str, tuple[str, ...]]
body: _Body
chunk_lengths: tuple[int, ...] | None = Field(default=None, alias="x-litellm-chunk-lengths")
def recorded_response(self) -> RecordedResponse:
headers: Final = tuple(
HttpHeader(name=name, value=value) for name, values in self.headers.items() for value in values
)
body: Final = self.body.as_bytes()
if self.chunk_lengths is None:
return RecordedHttpResponse.from_bytes(self.status.code, headers, body)
if any(length < 0 for length in self.chunk_lengths) or sum(self.chunk_lengths) != len(body):
raise ValueError("cassette stream chunk lengths do not match the response body")
offsets: Final = tuple(accumulate(self.chunk_lengths, initial=0))
return RecordedHttpStreamResponse(
kind="http_stream",
status_code=self.status.code,
headers=headers,
chunks=tuple(RecordedStreamChunk.from_bytes(body[start:end]) for start, end in zip(offsets, offsets[1:])),
)
class _Interaction(_CassetteModel):
request: _Request
response: _Response
class _ParityMetadata(_CassetteModel):
schema_version: Literal[1]
request_source: Literal["recorded", "python_replay"]
case: dict[str, object]
class ParityCassette(_CassetteModel):
version: Literal[1]
recorded_at: AwareDatetime
ttl_seconds: Literal[0]
interactions: tuple[_Interaction, ...]
parity: _ParityMetadata = Field(alias="x-litellm")
def case_data(self) -> dict[str, object]:
return {
**self.parity.case,
"provider_responses": tuple(item.response.recorded_response() for item in self.interactions),
}
def _response_dict(response: RecordedResponse) -> dict[str, object]:
headers: Final = {
name: [header.value for header in response.headers if header.name == name]
for name in dict.fromkeys(header.name for header in response.headers)
}
chunks: Final = (
tuple(chunk.data_bytes() for chunk in response.chunks)
if isinstance(response, RecordedHttpStreamResponse)
else None
)
body: Final = response.body_bytes() if isinstance(response, RecordedHttpResponse) else b"".join(chunks or ())
return {
"status": {"code": response.status_code, "message": ""},
"headers": headers,
"body": {"string": body},
**({"x-litellm-chunk-lengths": list(map(len, chunks))} if chunks is not None else {}),
}
def serialize_cassette(
case: Mapping[str, object],
interactions: tuple[RecordedInteraction, ...],
recorded_at: datetime,
request_source: Literal["recorded", "python_replay"],
) -> str:
normalized: Final = _OBJECT.validate_python(
yamlserializer.deserialize(
serialize(
{
"requests": [item.request for item in interactions],
"responses": [_response_dict(item.response) for item in interactions],
},
yamlserializer,
)
)
)
payload: Final = {
**normalized,
"recorded_at": recorded_at.isoformat(),
"ttl_seconds": 0,
"x-litellm": {
"schema_version": 1,
"request_source": request_source,
"case": {key: value for key, value in case.items() if key != "provider_responses"},
},
}
ParityCassette.model_validate(payload).case_data()
return str(yamlserializer.serialize(payload))
def deserialize_cassette(contents: str) -> ParityCassette:
return ParityCassette.model_validate(yamlserializer.deserialize(contents))

View file

@ -0,0 +1,34 @@
from __future__ import annotations
import argparse
from collections.abc import Sequence
from dataclasses import dataclass
from pathlib import Path
from typing import Final, cast
@dataclass(frozen=True, slots=True)
class RecordingArgs:
concurrency: int
examples: int
fixture_dir: Path | None
def _positive_int(value: str) -> int:
parsed: Final = int(value)
if parsed < 1:
raise argparse.ArgumentTypeError("must be at least 1")
return parsed
def parse_recording_args(argv: Sequence[str] | None = None) -> RecordingArgs:
parser: Final = argparse.ArgumentParser()
parser.add_argument("--concurrency", type=_positive_int, default=4)
parser.add_argument("--examples", type=_positive_int, default=4)
parser.add_argument("--fixture-dir", type=Path)
namespace: Final = parser.parse_args(argv)
return RecordingArgs(
concurrency=cast(int, namespace.concurrency),
examples=cast(int, namespace.examples),
fixture_dir=cast(Path | None, namespace.fixture_dir),
)

View file

@ -0,0 +1,22 @@
from __future__ import annotations
import queue
from typing import Final, TypeVar
from hypothesis import given, settings
from hypothesis.strategies import SearchStrategy
InputT = TypeVar("InputT")
def generate_case_inputs(strategy: SearchStrategy[InputT], examples: int) -> tuple[InputT, ...]:
generated: Final[queue.SimpleQueue[InputT | None]] = queue.SimpleQueue()
@settings(max_examples=examples, deadline=None, derandomize=True)
@given(case_input=strategy)
def generate_case(case_input: InputT) -> None:
generated.put(case_input)
generate_case()
generated.put(None)
return tuple(iter(generated.get, None))

View file

@ -0,0 +1,225 @@
from __future__ import annotations
import base64
from functools import cache
from io import BytesIO
from typing import Final
from urllib.parse import quote
from PIL import Image, ImageDraw
from reportlab.graphics.barcode import code128 # pyright: ignore[reportMissingTypeStubs] # ReportLab has no stubs
from reportlab.lib import colors # pyright: ignore[reportMissingTypeStubs] # ReportLab has no stubs
from reportlab.lib.pagesizes import letter # pyright: ignore[reportMissingTypeStubs] # ReportLab has no stubs
from reportlab.lib.utils import ImageReader # pyright: ignore[reportMissingTypeStubs] # ReportLab has no stubs
from reportlab.pdfgen import canvas # pyright: ignore[reportMissingTypeStubs] # ReportLab has no stubs
def dummy_image_url(text: str, font_size: int, width: int = 800, height: int = 300) -> str:
return f"https://dummyjson.com/image/{width}x{height}/ffffff/000000?text={quote(text)}&fontSize={font_size}"
_GLYPHS: Final = {
"D": ("11110", "10001", "10001", "10001", "10001", "10001", "11110"),
"O": ("01110", "10001", "10001", "10001", "10001", "10001", "01110"),
"C": ("01111", "10000", "10000", "10000", "10000", "10000", "01111"),
"1": ("00100", "01100", "00100", "00100", "00100", "00100", "01110"),
"2": ("01110", "10001", "00001", "00010", "00100", "01000", "11111"),
"3": ("11110", "00001", "00001", "01110", "00001", "00001", "11110"),
}
@cache
def structured_image_bytes() -> bytes:
image: Final = Image.new("RGB", (320, 80), "white")
draw: Final = ImageDraw.Draw(image)
scale: Final = 8
cursor_x = 24
for character in "DOC 123":
if character == " ":
cursor_x += scale * 3
continue
for glyph_y, row in enumerate(_GLYPHS[character]):
for glyph_x, filled in enumerate(row):
if filled == "1":
x = cursor_x + glyph_x * scale
y = 12 + glyph_y * scale
draw.rectangle((x, y, x + scale - 1, y + scale - 1), fill="black")
cursor_x += scale * 6
output: Final = BytesIO()
image.save(output, format="PNG")
return output.getvalue()
@cache
def structured_image_data_uri() -> str:
encoded: Final = base64.b64encode(structured_image_bytes()).decode("ascii")
return f"data:image/png;base64,{encoded}"
def _draw_header(pdf: canvas.Canvas, title: str, page_number: int) -> None:
pdf.setFillColor(colors.black)
pdf.setFont("Helvetica", 11)
pdf.drawString(45, 770, "Quarterly Operations Report")
pdf.setFont("Helvetica-Bold", 16)
pdf.drawString(45, 745, title)
pdf.setFont("Helvetica", 9)
pdf.drawString(45, 30, f"Confidential | Page {page_number} of 5")
def _draw_body(pdf: canvas.Canvas, page_number: int) -> None:
pdf.setFont("Helvetica", 10)
for line_number in range(1, 9):
pdf.drawString(
45,
500 - (line_number * 28),
f"Section {page_number}.{line_number}: Invoice totals, regional revenue, and reconciliation notes.",
)
def _diagram_image(width: int, height: int, accent: tuple[int, int, int]) -> Image.Image:
image: Final = Image.new("RGB", (width, height), (242, 246, 252))
draw: Final = ImageDraw.Draw(image)
for coordinate in range(0, max(width, height), 40):
draw.line((coordinate, 0, coordinate, height), fill=(32, 32, 32), width=3)
draw.line((0, coordinate, width, coordinate), fill=(32, 32, 32), width=3)
draw.line((0, 0, width, height), fill=accent, width=8)
draw.line((width, 0, 0, height), fill=accent, width=8)
draw.rectangle((width // 4, height // 4, width * 3 // 4, height * 3 // 4), outline=accent, width=6)
return image
def _draw_embedded_images(pdf: canvas.Canvas) -> None:
images: Final = (
(_diagram_image(320, 320, (51, 115, 217)), 455, 655, 70, 70),
(_diagram_image(360, 320, (38, 151, 92)), 455, 565, 70, 62),
(_diagram_image(120, 120, (219, 68, 55)), 455, 500, 45, 45),
)
for image, x, y, width, height in images:
pdf.drawImage( # pyright: ignore[reportUnknownMemberType] # ReportLab has no stubs
ImageReader(image), x, y, width=width, height=height, mask="auto"
)
def _draw_table_page(pdf: canvas.Canvas) -> None:
columns: Final = (45, 245, 405, 565)
tables: Final = (
(
(730, 695, 660, 625),
(
("Item", "Quantity", "Amount", 707),
("Document analysis", "2", "120.00", 672),
("Document verification", "1", "80.00", 637),
),
),
(
(600, 565, 530, 495),
(
("Item continued", "Quantity", "Amount", 577),
("Fixture validation", "3", "45.00", 542),
("Provider review", "1", "25.00", 507),
),
),
)
for rows, values in tables:
for x in columns:
pdf.line(x, rows[-1], x, rows[0])
for y in rows:
pdf.line(45, y, 565, y)
for item, quantity, amount, y in values:
pdf.drawString(55, y, item)
pdf.drawString(255, y, quantity)
pdf.drawString(415, y, amount)
def _draw_chart_page(pdf: canvas.Canvas) -> None:
bars: Final = ((70, 70), (170, 115), (270, 90), (370, 130))
pdf.setFillColor(colors.HexColor("#3373D9"))
for x, height in bars:
pdf.rect(x, 610, 65, height, fill=1, stroke=0)
pdf.setFillColor(colors.black)
for quarter, x in zip(("Q1", "Q2", "Q3", "Q4"), (90, 190, 290, 390), strict=True):
pdf.drawString(x, 590, quarter)
pdf.drawString(45, 550, "Formula: gross margin = (revenue - cost) / revenue")
_draw_embedded_images(pdf)
def _draw_metadata_page(pdf: canvas.Canvas) -> None:
pdf.setFont("Helvetica", 12)
pdf.drawString(45, 700, "Invoice Number: INV-2048")
pdf.drawString(45, 675, "Purchase Order: PO-4096")
pdf.setFillColor(colors.HexColor("#F2E65A"))
pdf.rect(40, 555, 500, 24, fill=1, stroke=0)
pdf.setFillColor(colors.black)
pdf.drawString(45, 560, "Highlighted total requiring review")
pdf.drawString(45, 530, "Reviewer comment: verify the highlighted total before approval")
pdf.setFillColor(colors.red)
pdf.drawString(45, 495, "Revised total: 245.00")
pdf.line(45, 501, 150, 501)
pdf.setFillColor(colors.black)
pdf.linkURL( # pyright: ignore[reportUnknownMemberType] # ReportLab has no stubs
"https://example.com/invoices/INV-2048", (45, 575, 300, 590), relative=0
)
pdf.highlightAnnotation( # pyright: ignore[reportUnknownMemberType] # ReportLab has no stubs
"Total highlighted for review",
Rect=(40, 555, 540, 579),
QuadPoints=(40, 579, 540, 579, 40, 555, 540, 555),
)
pdf.textAnnotation( # pyright: ignore[reportUnknownMemberType] # ReportLab has no stubs
"Verify the highlighted total", Rect=(520, 525, 540, 545)
)
pdf.drawString(45, 575, "https://example.com/invoices/INV-2048")
barcode: Final = code128.Code128("5901234123457", barHeight=70, barWidth=1.2)
barcode.drawOn(pdf, 90, 130)
def _draw_signature_page(pdf: canvas.Canvas) -> None:
pdf.saveState()
pdf.setFillColor(colors.lightgrey)
pdf.setFont("Helvetica-Bold", 54)
pdf.translate(110, 390)
pdf.rotate(25)
pdf.drawString(0, 0, "DRAFT")
pdf.restoreState()
pdf.setFillColor(colors.black)
pdf.setFont("Helvetica", 12)
pdf.drawString(45, 635, "Approved by: Jordan Lee")
pdf.line(45, 610, 310, 610)
pdf.bezier(55, 595, 75, 625, 112, 602, 155, 600)
pdf.drawString(45, 580, "Signature")
def _draw_appendix_page(pdf: canvas.Canvas) -> None:
pdf.setFont("Helvetica-Bold", 14)
pdf.drawString(45, 700, "1. Scope")
pdf.drawString(45, 650, "2. Findings")
pdf.drawString(45, 600, "3. Recommendations")
@cache
def structured_pdf_bytes() -> bytes:
output: Final = BytesIO()
pdf: Final = canvas.Canvas(output, pagesize=letter, pageCompression=0, invariant=1)
pdf.setTitle("Quarterly Operations Report")
pdf.setAuthor("LiteLLM parity fixture generator")
pdf.setSubject("Semantic document coverage for tables, figures, annotations, and metadata")
pdf.setKeywords("document, invoice, table, figure, annotation")
pages: Final = (
("Invoice Summary and Line Items", _draw_table_page),
("Revenue Chart and Formula Review", _draw_chart_page),
("Key Values, Link, Highlight, and Comment", _draw_metadata_page),
("Approval Signature and Watermark", _draw_signature_page),
("Appendix with Section Boundaries", _draw_appendix_page),
)
for page_number, (title, draw_page) in enumerate(pages, start=1):
_draw_header(pdf, title, page_number)
draw_page(pdf)
_draw_body(pdf, page_number)
pdf.showPage()
pdf.save()
return output.getvalue()
@cache
def structured_pdf_data_uri() -> str:
encoded: Final = base64.b64encode(structured_pdf_bytes()).decode("ascii")
return f"data:application/pdf;base64,{encoded}"

View file

@ -0,0 +1,198 @@
from __future__ import annotations
import logging
from concurrent.futures import Future, ThreadPoolExecutor, as_completed
from dataclasses import dataclass, field
from pathlib import Path
from types import MappingProxyType
from typing import Final, Generic, Literal, Protocol, TypeVar
from hypothesis.strategies import SearchStrategy
from pydantic import BaseModel
from tests.route_parity.fixtures.inputs import generate_case_inputs
from tests.route_parity.fixtures.recording import UpstreamEndpoint, record_upstream_interactions
from tests.route_parity.fixtures.store import (
FixtureInput,
canonical_json,
fixture_cache_key,
fixture_id,
fixture_path,
load_fixture,
save_fixture,
)
LOGGER: Final = logging.getLogger(__name__)
InputT = TypeVar("InputT", bound=FixtureInput)
InputT_contra = TypeVar("InputT_contra", bound=FixtureInput, contravariant=True)
CaseT = TypeVar("CaseT", bound=BaseModel)
class RecordingInvocation(Protocol[InputT_contra]):
def execute(self, provider_url: str, case_input: InputT_contra) -> None: ...
@dataclass(frozen=True, slots=True)
class RecordingTarget(Generic[InputT]):
name: str
upstream: UpstreamEndpoint
strategy: SearchStrategy[InputT]
invocation: RecordingInvocation[InputT] = field(repr=False)
required_inputs: tuple[InputT, ...] = ()
@dataclass(frozen=True, slots=True)
class RecordingJob(Generic[InputT]):
target_name: str
directory: Path
upstream: UpstreamEndpoint
case_input: InputT
invocation: RecordingInvocation[InputT] = field(repr=False)
@property
def case_id(self) -> str:
return fixture_id(self.case_input, self.target_name)
@dataclass(frozen=True, slots=True)
class RecordedFixture:
target_name: str
case_id: str
path: Path
kind: Literal["recorded"] = field(default="recorded", init=False)
@dataclass(frozen=True, slots=True)
class CachedFixture:
target_name: str
case_id: str
path: Path
kind: Literal["cached"] = field(default="cached", init=False)
@dataclass(frozen=True, slots=True)
class FailedFixture:
target_name: str
case_id: str
error: Exception = field(repr=False)
kind: Literal["failed"] = field(default="failed", init=False)
RecordingOutcome = RecordedFixture | CachedFixture | FailedFixture
@dataclass(frozen=True, slots=True)
class RecordingSummary:
recorded: tuple[RecordedFixture, ...]
cached: tuple[CachedFixture, ...]
failed: tuple[FailedFixture, ...]
@property
def exit_code(self) -> int:
return 1 if self.failed else 0
def _unique_inputs(target: RecordingTarget[InputT], examples: int) -> tuple[InputT, ...]:
generated_inputs: Final = generate_case_inputs(target.strategy, examples)
case_inputs: Final = (*target.required_inputs, *generated_inputs)
return tuple({canonical_json(fixture_cache_key(case_input)): case_input for case_input in case_inputs}.values())
def build_recording_jobs(
targets: tuple[RecordingTarget[InputT], ...],
root: Path,
examples: int,
) -> tuple[RecordingJob[InputT], ...]:
if examples < 1:
raise ValueError("examples must be at least 1")
return tuple(
RecordingJob(
target_name=target.name,
directory=root / target.name,
upstream=target.upstream,
case_input=case_input,
invocation=target.invocation,
)
for target in targets
for case_input in _unique_inputs(target, examples)
)
def _record_job(job: RecordingJob[InputT], case_type: type[CaseT]) -> RecordedFixture | CachedFixture:
cached: Final = load_fixture(job.directory, job.case_input, case_type)
if cached is not None:
path: Final = fixture_path(job.directory, job.case_input)
return CachedFixture(
target_name=job.target_name,
case_id=job.case_id,
path=path if path.is_file() else path.with_suffix(".json"),
)
interactions: Final = record_upstream_interactions(
job.upstream,
job.case_input,
job.invocation.execute,
)
case: Final = case_type.model_validate(
{
"litellm_input": job.case_input,
"provider_responses": tuple(item.response for item in interactions),
}
)
saved_path: Final = save_fixture(job.directory, job.case_input, case, interactions)
return RecordedFixture(target_name=job.target_name, case_id=job.case_id, path=saved_path)
def _completed_outcome(
completed: int,
total: int,
job: RecordingJob[InputT],
future: Future[RecordedFixture | CachedFixture],
) -> RecordingOutcome:
try:
outcome: Final = future.result()
except Exception as error:
failed: Final = FailedFixture(target_name=job.target_name, case_id=job.case_id, error=error)
LOGGER.error(
"[%d/%d] failed %s %s: %s",
completed,
total,
failed.target_name,
failed.case_id,
type(error).__name__,
)
return failed
LOGGER.info("[%d/%d] %s %s %s", completed, total, outcome.kind, outcome.target_name, outcome.case_id)
return outcome
def record_fixtures(
targets: tuple[RecordingTarget[InputT], ...],
root: Path,
examples: int,
concurrency: int,
case_type: type[CaseT],
) -> RecordingSummary:
if concurrency < 1:
raise ValueError("concurrency must be at least 1")
jobs: Final = build_recording_jobs(targets, root, examples)
total: Final = len(jobs)
LOGGER.info("Recording %d fixtures across %d targets with concurrency %d", total, len(targets), concurrency)
with ThreadPoolExecutor(max_workers=concurrency) as executor:
future_jobs: Final = MappingProxyType({executor.submit(_record_job, job, case_type): job for job in jobs})
outcomes: Final = tuple(
_completed_outcome(completed, total, future_jobs[future], future)
for completed, future in enumerate(as_completed(future_jobs), start=1)
)
summary: Final = RecordingSummary(
recorded=tuple(outcome for outcome in outcomes if isinstance(outcome, RecordedFixture)),
cached=tuple(outcome for outcome in outcomes if isinstance(outcome, CachedFixture)),
failed=tuple(outcome for outcome in outcomes if isinstance(outcome, FailedFixture)),
)
LOGGER.info(
"Finished %d fixtures: %d recorded, %d cached, %d failed",
total,
len(summary.recorded),
len(summary.cached),
len(summary.failed),
)
return summary

View file

@ -0,0 +1,66 @@
from __future__ import annotations
import os
from collections.abc import Callable
from pathlib import Path
from typing import Final, TypeVar
import pytest
from pydantic import BaseModel, ValidationError
from tests.route_parity.fixtures.store import recorded_fixtures
CaseT = TypeVar("CaseT", bound=BaseModel)
def parametrize_recorded_fixtures(
metafunc: pytest.Metafunc,
*,
fixture_name: str,
case_type: type[CaseT],
env_var: str,
default_directory: Path,
regeneration_command: str,
id_builder: Callable[[CaseT], str],
marks_builder: Callable[[CaseT], tuple[pytest.MarkDecorator, ...]] | None = None,
) -> None:
if fixture_name not in metafunc.fixturenames:
return
configured: Final = os.environ.get(env_var)
if configured == "":
raise pytest.UsageError(f"{env_var} is set but empty")
directory: Final = Path(configured).expanduser() if configured is not None else default_directory
try:
fixtures: Final = recorded_fixtures(directory, case_type)
except (ValidationError, ValueError) as error:
raise pytest.UsageError(
f"Invalid parity fixture bundle at {directory}. "
"Each fixture must use the current versioned envelope. "
f"Record fresh fixtures in an empty directory with: `{regeneration_command}`. "
f"Validation details: {error}"
) from error
if fixtures:
metafunc.parametrize(
fixture_name,
tuple(
pytest.param(
fixture,
id=id_builder(fixture),
marks=marks_builder(fixture) if marks_builder is not None else (),
)
for fixture in fixtures
),
)
return
if configured is not None:
raise pytest.UsageError(f"no recorded fixtures in {directory}")
metafunc.parametrize(
fixture_name,
(
pytest.param(
None,
marks=pytest.mark.skip(reason=f"no recorded fixtures in {directory}"),
id="no-recorded-fixtures",
),
),
)

View file

@ -0,0 +1,267 @@
from __future__ import annotations
import queue
import threading
from collections.abc import Callable, Generator, Iterable
from contextlib import contextmanager
from dataclasses import dataclass
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Final, TypeVar, cast
from urllib.parse import urlsplit, urlunsplit
import httpx
from vcr.filters import remove_query_parameters
from vcr.request import Request
from tests.provider_record_replay.http import (
dropped_request_headers,
dropped_response_headers,
is_streaming_response,
)
from tests.route_parity.recorded_http import (
HttpHeader,
RecordedHttpResponse,
RecordedHttpStreamResponse,
RecordedResponse,
RecordedStreamChunk,
)
_PARITY_PROVIDER_HOST: Final = "parity-provider.invalid"
_SECRET_HEADERS: Final = frozenset(
{
"authorization",
"proxy-authorization",
"cookie",
"x-api-key",
"api-key",
"anthropic-api-key",
"openai-api-key",
"azure-api-key",
"x-goog-api-key",
"ocp-apim-subscription-key",
"x-amz-security-token",
}
)
InputT = TypeVar("InputT")
@dataclass(frozen=True, slots=True)
class UpstreamEndpoint:
base_url: str
@dataclass(frozen=True, slots=True)
class RecordedInteraction:
request: Request
response: RecordedResponse
def _end_to_end_headers(headers: httpx.Headers) -> tuple[HttpHeader, ...]:
decoded: Final = tuple((name.decode("ascii"), value.decode("latin-1")) for name, value in headers.raw)
excluded: Final = dropped_response_headers(decoded)
return tuple(
HttpHeader(name=name, value=_normalized_response_header(name, value))
for name, value in decoded
if name.lower() not in excluded
)
def _normalized_response_header(name: str, value: str) -> str:
if name.lower() not in {"location", "operation-location"}:
return value
parsed: Final = urlsplit(value)
if not parsed.netloc:
return value
return urlunsplit(("http", _PARITY_PROVIDER_HOST, parsed.path, parsed.query, parsed.fragment))
def local_response_header(name: str, value: str, provider_url: str) -> str:
if name.lower() not in {"location", "operation-location"}:
return value
parsed: Final = urlsplit(value)
if parsed.hostname != _PARITY_PROVIDER_HOST:
return value
return f"{provider_url}{parsed.path}{'?' + parsed.query if parsed.query else ''}"
class _RecordingProvider(ThreadingHTTPServer):
daemon_threads = True
def __init__(self, spec: UpstreamEndpoint) -> None:
super().__init__(("127.0.0.1", 0), _RecordingHandler)
self.spec: Final = spec
self.interactions: queue.Queue[RecordedInteraction] = queue.Queue()
@property
def url(self) -> str:
return f"http://127.0.0.1:{self.server_address[1]}"
def take_interactions(self) -> tuple[RecordedInteraction, ...]:
try:
first: Final = self.interactions.get(timeout=5)
except queue.Empty as error:
raise RuntimeError("successful SDK call did not produce a recorded response") from error
remaining: Final = tuple(self.interactions.get_nowait() for _ in range(self.interactions.qsize()))
return (first, *remaining)
class _RecordingHandler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def do_POST(self) -> None:
self._forward()
def do_GET(self) -> None:
self._forward()
def do_PUT(self) -> None:
self._forward()
def do_PATCH(self) -> None:
self._forward()
def do_DELETE(self) -> None:
self._forward()
def _forward(self) -> None:
provider: Final = self.server
assert isinstance(provider, _RecordingProvider)
length: Final = int(self.headers.get("content-length") or "0")
request_body: Final = self.rfile.read(length) if length else b""
raw_headers: Final = tuple(self.headers.raw_items())
excluded: Final = dropped_request_headers(raw_headers)
forwarded_headers: Final = tuple((name, value) for name, value in raw_headers if name.lower() not in excluded)
upstream_url: Final = f"{provider.spec.base_url.rstrip('/')}{self.path}"
try:
with httpx.stream(
self.command,
upstream_url,
headers=forwarded_headers,
content=request_body,
timeout=120,
) as upstream:
headers: Final = _end_to_end_headers(upstream.headers)
recorded_response: Final = self._record_upstream_response(upstream, headers)
except httpx.HTTPError as error:
self._send_response(502, (), str(error).encode("utf-8"))
return
recorded_request: Final = remove_query_parameters(
Request(
self.command,
f"http://{_PARITY_PROVIDER_HOST}{self.path}",
request_body,
{name: value for name, value in forwarded_headers if name.lower() not in _SECRET_HEADERS},
),
("api_key", "api-key", "key", "access_token", "subscription-key"),
)
provider.interactions.put(RecordedInteraction(recorded_request, recorded_response))
if isinstance(recorded_response, RecordedHttpResponse):
self._send_response(
recorded_response.status_code, recorded_response.headers, recorded_response.body_bytes()
)
def _record_upstream_response(
self,
upstream: httpx.Response,
headers: tuple[HttpHeader, ...],
) -> RecordedResponse:
content_type: Final = cast(str, upstream.headers.get("content-type", ""))
if is_streaming_response(content_type):
return self._record_stream(upstream, headers)
response_body: Final = b"".join(upstream.iter_bytes())
return RecordedHttpResponse.from_bytes(
status_code=upstream.status_code,
headers=headers,
body=response_body,
)
def _record_stream(
self,
upstream: httpx.Response,
headers: tuple[HttpHeader, ...],
) -> RecordedHttpStreamResponse:
self.send_response_only(upstream.status_code)
provider: Final = self.server
assert isinstance(provider, _RecordingProvider)
for header in headers:
self.send_header(header.name, local_response_header(header.name, header.value, provider.url))
self.send_header("transfer-encoding", "chunked")
self.end_headers()
chunks: Final = tuple(self._relay_chunks(upstream.iter_bytes()))
self.wfile.write(b"0\r\n\r\n")
self.wfile.flush()
return RecordedHttpStreamResponse(
kind="http_stream",
status_code=upstream.status_code,
headers=headers,
chunks=chunks,
)
def _relay_chunks(self, chunks: Iterable[bytes]) -> Generator[RecordedStreamChunk, None, None]:
for chunk in chunks:
self.wfile.write(f"{len(chunk):X}\r\n".encode("ascii"))
self.wfile.write(chunk)
self.wfile.write(b"\r\n")
self.wfile.flush()
yield RecordedStreamChunk.from_bytes(chunk)
def _send_response(self, status_code: int, headers: tuple[HttpHeader, ...], body: bytes) -> None:
self.send_response_only(status_code)
provider: Final = self.server
assert isinstance(provider, _RecordingProvider)
for header in headers:
self.send_header(header.name, local_response_header(header.name, header.value, provider.url))
self.send_header("content-length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, format: str, *args: object) -> None:
return
@contextmanager
def _recording_provider(spec: UpstreamEndpoint) -> Generator[_RecordingProvider]:
server: Final = _RecordingProvider(spec)
thread: Final = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
yield server
finally:
server.shutdown()
server.server_close()
thread.join(timeout=5)
def _invoke_and_take_interactions(
recorder: _RecordingProvider,
case_input: InputT,
sdk_call: Callable[[str, InputT], object],
) -> tuple[RecordedInteraction, ...]:
try:
sdk_call(recorder.url, case_input)
except Exception as invocation_error:
try:
return recorder.take_interactions()
except RuntimeError:
raise invocation_error
return recorder.take_interactions()
def record_upstream_interactions(
spec: UpstreamEndpoint,
case_input: InputT,
sdk_call: Callable[[str, InputT], object],
) -> tuple[RecordedInteraction, ...]:
with _recording_provider(spec) as recorder:
return _invoke_and_take_interactions(recorder, case_input, sdk_call)
def record_upstream_responses(
spec: UpstreamEndpoint,
case_input: InputT,
sdk_call: Callable[[str, InputT], object],
) -> tuple[RecordedResponse, ...]:
return tuple(item.response for item in record_upstream_interactions(spec, case_input, sdk_call))

View file

@ -0,0 +1,126 @@
from __future__ import annotations
import hashlib
import json
import tempfile
from collections.abc import Mapping
from datetime import datetime, timezone
from pathlib import Path
from typing import Final, Literal, Protocol, TypeVar, cast
from pydantic import AwareDatetime, BaseModel, ConfigDict, TypeAdapter, ValidationError
from tests.route_parity.fixtures.cassette import deserialize_cassette, serialize_cassette
from tests.route_parity.fixtures.recording import RecordedInteraction
FIXTURE_SCHEMA_VERSION: Final = 1
JSON_OBJECT: Final = TypeAdapter(dict[str, object])
class FixtureInput(Protocol):
def canonical_input(self) -> dict[str, object]: ...
CaseT = TypeVar("CaseT", bound=BaseModel)
class FixtureEnvelope(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
schema_version: int
recorded_at: AwareDatetime
case: dict[str, object]
def canonical_json(value: Mapping[str, object]) -> str:
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
def fixture_cache_key(case_input: FixtureInput) -> dict[str, object]:
return case_input.canonical_input()
def fixture_path(directory: Path, case_input: FixtureInput) -> Path:
input_json: Final = canonical_json(fixture_cache_key(case_input))
digest: Final = hashlib.sha256(input_json.encode("utf-8")).hexdigest()
return directory / f"{digest}.yaml"
def load_fixture(directory: Path, case_input: FixtureInput, case_type: type[CaseT]) -> CaseT | None:
path: Final = fixture_path(directory, case_input)
if path.is_file():
return read_fixture(path, case_type)
legacy_path: Final = path.with_suffix(".json")
if not legacy_path.is_file():
return None
return read_fixture(legacy_path, case_type)
def save_fixture(
directory: Path,
case_input: FixtureInput,
case: BaseModel,
interactions: tuple[RecordedInteraction, ...],
*,
recorded_at: datetime | None = None,
request_source: Literal["recorded", "python_replay"] = "recorded",
) -> Path:
directory.mkdir(parents=True, exist_ok=True)
path: Final = fixture_path(directory, case_input)
serialized: Final = serialize_cassette(
cast(dict[str, object], case.model_dump(mode="json", exclude_unset=True)),
interactions,
recorded_at or datetime.now(timezone.utc),
request_source,
)
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=directory, delete=False) as temporary:
temporary_path: Final = Path(temporary.name)
try:
temporary.write(serialized)
temporary.close()
temporary_path.replace(path)
finally:
temporary_path.unlink(missing_ok=True)
return path
def read_fixture(path: Path, case_type: type[CaseT]) -> CaseT:
contents: Final = path.read_text(encoding="utf-8")
if path.suffix == ".json":
return _load_fixture(JSON_OBJECT.validate_json(contents), path, case_type)
try:
cassette: Final = deserialize_cassette(contents)
return case_type.model_validate(cassette.case_data())
except ValueError as error:
raise ValueError(f"invalid parity cassette {path}") from error
def _load_fixture(raw_fixture: dict[str, object], path: Path, case_type: type[CaseT]) -> CaseT:
schema_version: Final = raw_fixture.get("schema_version")
if schema_version != FIXTURE_SCHEMA_VERSION:
raise ValueError(
f"fixture {path} has schema_version {schema_version!r}, expected {FIXTURE_SCHEMA_VERSION}; "
"delete it and regenerate the fixture bundle"
)
try:
envelope: Final = FixtureEnvelope.model_validate(raw_fixture)
return case_type.model_validate(envelope.case)
except ValidationError as error:
raise ValueError(f"invalid parity fixture {path} ({len(error.errors())} validation errors)") from error
def recorded_fixtures(directory: Path, case_type: type[CaseT]) -> tuple[CaseT, ...]:
if not directory.is_dir():
return ()
paths: Final = tuple(sorted((*directory.rglob("*.yaml"), *directory.rglob("*.json"))))
return tuple(read_fixture(path, case_type) for path in paths)
def fixture_directory(configured: Path | None, env_value: str | None, default: Path) -> Path:
return (configured or Path(env_value or default)).expanduser()
def fixture_id(case_input: FixtureInput, prefix: str) -> str:
input_json: Final = canonical_json(case_input.canonical_input())
digest: Final = hashlib.sha256(input_json.encode("utf-8")).hexdigest()[:8]
return f"{prefix}-{digest}"

View file

@ -0,0 +1,95 @@
from __future__ import annotations
from datetime import datetime, timezone
from pathlib import Path
from typing import Final
import httpx
import pytest
from vcr import VCR
from vcr.request import Request
from tests.route_parity.fixture_models import ParityCase, SdkInputBase
from tests.route_parity.fixtures.cassette import deserialize_cassette
from tests.route_parity.fixtures.recording import RecordedInteraction
from tests.route_parity.fixtures.store import load_fixture, save_fixture
from tests.route_parity.recorded_http import (
HttpHeader,
RecordedHttpResponse,
RecordedHttpStreamResponse,
RecordedResponse,
RecordedStreamChunk,
)
from tests.route_parity.replay import replay_server
_URI: Final = "http://parity-provider.invalid/operation?api-version=1"
class _Input(SdkInputBase):
model: str = "fixture-model"
@pytest.mark.parametrize("body", (b'{"text":"caf\xc3\xa9"}', b"\x00\xff\x80", b""))
def test_cassette_replays_repeated_requests_with_vcr_and_preserves_bytes(tmp_path: Path, body: bytes) -> None:
sdk_input: Final = _Input()
responses: Final = tuple(
RecordedHttpResponse.from_bytes(
status,
(HttpHeader(name="content-type", value="application/octet-stream"),),
body,
)
for status in (200, 429)
)
case: Final = ParityCase[_Input](litellm_input=sdk_input, provider_responses=responses)
interactions: Final = tuple(
RecordedInteraction(Request("POST", _URI, b"\xffrequest", {}), response) for response in responses
)
timestamp: Final = datetime(2020, 1, 1, tzinfo=timezone.utc)
path: Final = save_fixture(tmp_path, sdk_input, case, interactions, recorded_at=timestamp)
assert load_fixture(tmp_path, sdk_input, ParityCase[_Input]) == case
assert deserialize_cassette(path.read_text()).recorded_at == timestamp
with VCR().use_cassette(str(path), record_mode="none", match_on=("method", "uri", "body")) as cassette:
for status in (200, 429):
replayed: Final = httpx.post(_URI, content=b"\xffrequest")
assert replayed.status_code == status
assert replayed.content == body
assert cassette.all_played
def test_stream_cassette_preserves_chunk_boundaries_through_local_replay(tmp_path: Path) -> None:
sdk_input: Final = _Input()
chunks: Final = (b"data: caf\xc3", b"\xa9\n\n", b"data: [DONE]\n\n")
response: Final = RecordedHttpStreamResponse(
kind="http_stream",
status_code=200,
headers=(HttpHeader(name="content-type", value="text/event-stream"),),
chunks=tuple(RecordedStreamChunk.from_bytes(chunk) for chunk in chunks),
)
case: Final = ParityCase[_Input](litellm_input=sdk_input, provider_responses=(response,))
path: Final = save_fixture(
tmp_path, sdk_input, case, (RecordedInteraction(Request("POST", _URI, b"{}", {}), response),)
)
loaded: Final = load_fixture(tmp_path, sdk_input, ParityCase[_Input])
assert loaded == case
with replay_server() as server:
server.enqueue_response(loaded.provider_responses[0])
with httpx.stream("POST", f"{server.url}/operation", content=b"{}") as replayed:
assert tuple(replayed.iter_raw()) == chunks
server.take_requests(1)
path.write_text(path.read_text().replace("- 10\n", "- 999\n"))
with pytest.raises(ValueError, match="invalid parity cassette"):
load_fixture(tmp_path, sdk_input, ParityCase[_Input])
def test_cassette_preserves_duplicate_response_headers(tmp_path: Path) -> None:
sdk_input: Final = _Input()
response: Final[RecordedResponse] = RecordedHttpResponse.from_bytes(
200,
(HttpHeader(name="x-test", value="first"), HttpHeader(name="x-test", value="second")),
b"{}",
)
case: Final = ParityCase[_Input](litellm_input=sdk_input, provider_responses=(response,))
save_fixture(tmp_path, sdk_input, case, (RecordedInteraction(Request("POST", _URI, b"", {}), response),))
assert load_fixture(tmp_path, sdk_input, ParityCase[_Input]) == case

View file

@ -0,0 +1,20 @@
from __future__ import annotations
from typing import Final
from hypothesis import strategies as st
from pydantic import BaseModel, ConfigDict
from tests.route_parity.fixtures.inputs import generate_case_inputs
class _Input(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
identifier: str
def test_generate_case_inputs_is_deterministic() -> None:
strategy: Final = st.builds(_Input, identifier=st.integers().map(str))
assert generate_case_inputs(strategy, examples=4) == generate_case_inputs(strategy, examples=4)

View file

@ -0,0 +1,27 @@
from __future__ import annotations
import base64
from io import BytesIO
from typing import Final, cast
from PIL import Image
from tests.route_parity.fixtures.media import dummy_image_url, structured_image_bytes, structured_image_data_uri
def test_dummy_image_url_encodes_text_and_dimensions() -> None:
assert dummy_image_url("invoice 123", 24, width=320, height=80) == (
"https://dummyjson.com/image/320x80/ffffff/000000?text=invoice%20123&fontSize=24"
)
def test_structured_image_is_local_content_bearing_png() -> None:
png: Final = structured_image_bytes()
encoded: Final = structured_image_data_uri().partition(",")[2]
image: Final = Image.open(BytesIO(png))
colors: Final = cast(list[tuple[int, tuple[int, int, int]]], image.getcolors(maxcolors=2))
assert png.startswith(b"\x89PNG\r\n\x1a\n")
assert base64.b64decode(encoded, validate=True) == png
assert image.size == (320, 80)
assert {color for _, color in colors} == {(0, 0, 0), (255, 255, 255)}

View file

@ -0,0 +1,191 @@
from __future__ import annotations
import logging
import threading
from collections.abc import Generator
from contextlib import contextmanager
from dataclasses import dataclass
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Final, Literal
import httpx
import pytest
from hypothesis import strategies as st
from pydantic import BaseModel, ConfigDict
from tests.route_parity.fixtures.pipeline import (
RecordingInvocation,
RecordingTarget,
build_recording_jobs,
record_fixtures,
)
from tests.route_parity.fixtures.recording import UpstreamEndpoint
from tests.route_parity.fixtures.store import fixture_path
from tests.route_parity.recorded_http import RecordedResponse
class _FixtureInput(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
identifier: str
def canonical_input(self) -> dict[str, object]:
return {"identifier": self.identifier}
class _ParityCase(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
litellm_input: _FixtureInput
provider_responses: tuple[RecordedResponse, ...]
class _Upstream(ThreadingHTTPServer):
daemon_threads = True
def __init__(self) -> None:
super().__init__(("127.0.0.1", 0), _UpstreamHandler)
@property
def url(self) -> str:
return f"http://127.0.0.1:{self.server_address[1]}"
class _UpstreamHandler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def do_POST(self) -> None:
length: Final = int(self.headers.get("content-length") or "0")
self.rfile.read(length)
body: Final = b"{}"
self.send_response(200)
self.send_header("content-type", "application/json")
self.send_header("content-length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, format: str, *args: object) -> None:
return
@contextmanager
def _upstream() -> Generator[_Upstream]:
server: Final = _Upstream()
thread: Final = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
yield server
finally:
server.shutdown()
server.server_close()
thread.join(timeout=5)
@dataclass(frozen=True, slots=True)
class _OrderedInvocation:
order: Literal["slow", "fast"]
slow_started: threading.Event
fast_finished: threading.Event
def execute(self, provider_url: str, case_input: _FixtureInput) -> None:
if self.order == "slow":
self.slow_started.set()
if not self.fast_finished.wait(timeout=2):
raise TimeoutError("fast recording did not finish")
else:
if not self.slow_started.wait(timeout=2):
raise TimeoutError("slow recording did not start")
response: Final = httpx.post(f"{provider_url}/record", json={"id": case_input.identifier}, timeout=5)
response.raise_for_status()
if self.order == "fast":
self.fast_finished.set()
@dataclass(frozen=True, slots=True)
class _Invocation:
def execute(self, provider_url: str, case_input: _FixtureInput) -> None:
response: Final = httpx.post(f"{provider_url}/record", json={"id": case_input.identifier}, timeout=5)
response.raise_for_status()
def _target(
name: str,
upstream_url: str,
case_input: _FixtureInput,
invocation: RecordingInvocation[_FixtureInput],
) -> RecordingTarget[_FixtureInput]:
return RecordingTarget(
name=name,
upstream=UpstreamEndpoint(base_url=upstream_url),
strategy=st.just(case_input),
invocation=invocation,
required_inputs=(case_input,),
)
def test_build_jobs_keeps_required_inputs_before_generated_inputs_and_deduplicates(tmp_path: Path) -> None:
required: Final = _FixtureInput(identifier="required")
generated: Final = _FixtureInput(identifier="generated")
target: Final = RecordingTarget(
name="ordered",
upstream=UpstreamEndpoint(base_url="https://provider.invalid"),
strategy=st.just(generated),
invocation=_Invocation(),
required_inputs=(required, required),
)
jobs: Final = build_recording_jobs((target,), tmp_path, examples=1)
assert tuple(job.case_input.identifier for job in jobs) == ("required", "generated")
def test_progress_follows_completion_order(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
slow_started: Final = threading.Event()
fast_finished: Final = threading.Event()
with _upstream() as upstream:
targets: Final = (
_target(
"slow",
upstream.url,
_FixtureInput(identifier="slow"),
_OrderedInvocation("slow", slow_started, fast_finished),
),
_target(
"fast",
upstream.url,
_FixtureInput(identifier="fast"),
_OrderedInvocation("fast", slow_started, fast_finished),
),
)
with caplog.at_level(logging.INFO, logger="tests.route_parity.fixtures.pipeline"):
summary: Final = record_fixtures(targets, tmp_path, 1, 2, _ParityCase)
progress: Final = tuple(record.message for record in caplog.records if record.message.startswith("["))
assert len(summary.recorded) == 2
assert summary.exit_code == 0
assert "recorded fast" in progress[0]
assert "recorded slow" in progress[1]
assert caplog.records[0].message == "Recording 2 fixtures across 2 targets with concurrency 2"
assert caplog.records[-1].message == "Finished 2 fixtures: 2 recorded, 0 cached, 0 failed"
def test_failure_does_not_stop_independent_recordings(tmp_path: Path) -> None:
stale_input: Final = _FixtureInput(identifier="stale")
stale_directory: Final = tmp_path / "stale"
stale_directory.mkdir()
fixture_path(stale_directory, stale_input).with_suffix(".json").write_text(
'{"schema_version": 0}\n', encoding="utf-8"
)
with _upstream() as upstream:
targets: Final = (
_target("stale", upstream.url, stale_input, _Invocation()),
_target("valid", upstream.url, _FixtureInput(identifier="valid"), _Invocation()),
)
summary: Final = record_fixtures(targets, tmp_path, 1, 2, _ParityCase)
assert len(summary.recorded) == 1
assert summary.recorded[0].target_name == "valid"
assert len(summary.failed) == 1
assert summary.failed[0].target_name == "stale"
assert summary.exit_code == 1

View file

@ -0,0 +1,574 @@
from __future__ import annotations
import asyncio
import queue
import threading
from collections.abc import AsyncIterator, Callable, Generator, Iterator
from contextlib import contextmanager
from dataclasses import dataclass
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Final, Literal
import httpx
import pytest
from hypothesis import strategies as st
from openai._streaming import SSEDecoder
from pydantic import BaseModel, ConfigDict
from tests.route_parity.compare import assert_request_parity
from tests.route_parity.fixtures.pipeline import RecordingTarget, record_fixtures
from tests.route_parity.fixtures.recording import (
UpstreamEndpoint,
record_upstream_interactions,
record_upstream_responses,
)
from tests.route_parity.fixtures.store import (
FIXTURE_SCHEMA_VERSION,
fixture_path,
load_fixture,
recorded_fixtures,
)
from tests.route_parity.inprocess import InProcessExecution, run_in_process, run_in_process_async
from tests.route_parity.recorded_http import (
HttpHeader,
RecordedHttpStreamResponse,
RecordedResponse,
RecordedStreamChunk,
)
from tests.route_parity.replay import ReplayServer, replay_server
from tests.route_parity.stream import (
StreamCompleted,
StreamFailed,
StreamOutcome,
assert_stream_parity,
consume_async_stream,
consume_sync_stream,
)
_SSE_CHUNKS: Final = (
b'data: {"choices":[{"delta":{"content":"hello"}}]}\n\n',
b'data: {"choices":[{"delta":{"content":" world"}}]}\n\n',
b"data: [DONE]\n\n",
)
class _StreamEvent(BaseModel):
kind: Literal["delta", "done", "error"]
value: str
class _StreamApplicationError(Exception):
status_code: Final = 400
code: Final = "invalid_input"
type: Final = "validation_error"
param: Final = "input"
model: Final = "fixture-model"
llm_provider: Final = "fixture-provider"
def _stream_event(data: str) -> _StreamEvent:
event: Final = _StreamEvent.model_validate_json(data)
if event.kind == "error":
raise _StreamApplicationError(event.value)
return event
def _event_chunks(failed: bool) -> tuple[bytes, ...]:
terminal: Final = (
b'event: error\r\ndata: {"kind":"error","value":"invalid input"}\r\n\r\n'
if failed
else b'event: done\r\ndata: {"kind":"done","value":""}\r\n\r\n'
)
return (
b'event: delta\r\ndata: {"kind":"delta",\r\ndata: "value":"caf\xc3',
b'\xa9"}\r\n',
b'\r\nevent: delta\r\ndata: {"kind":"delta","value":"second"}\r\n\r\n' + terminal,
)
def _sync_events(api_base: str, case_input: _FixtureInput) -> Iterator[_StreamEvent]:
with httpx.stream("POST", f"{api_base}/stream", json={"id": case_input.identifier}, timeout=5) as response:
response.raise_for_status()
for event in SSEDecoder().iter_bytes(response.iter_bytes()):
yield _stream_event(event.data)
async def _async_events(api_base: str, case_input: _FixtureInput) -> AsyncIterator[_StreamEvent]:
async with httpx.AsyncClient(timeout=5) as client:
async with client.stream("POST", f"{api_base}/stream", json={"id": case_input.identifier}) as response:
response.raise_for_status()
async for event in SSEDecoder().aiter_bytes(response.aiter_bytes()):
yield _stream_event(event.data)
async def _consume_async_events(api_base: str, case_input: _FixtureInput) -> StreamOutcome:
async def create() -> AsyncIterator[_StreamEvent]:
return _async_events(api_base, case_input)
return await consume_async_stream(create)
async def _replay_events(
mode: Literal["sync", "async"],
provider: ReplayServer,
response: RecordedHttpStreamResponse,
case_input: _FixtureInput,
) -> InProcessExecution[StreamOutcome]:
if mode == "sync":
return run_in_process(
provider, (response,), lambda url: consume_sync_stream(lambda: _sync_events(url, case_input))
)
return await run_in_process_async(provider, (response,), lambda url: _consume_async_events(url, case_input))
class _FixtureInput(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
identifier: str
def canonical_input(self) -> dict[str, object]:
return {"identifier": self.identifier}
class _ParityCase(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
litellm_input: _FixtureInput
provider_responses: tuple[RecordedResponse, ...]
@dataclass(frozen=True, slots=True)
class _Invocation:
sdk_call: Callable[[str, _FixtureInput], object]
def execute(self, provider_url: str, case_input: _FixtureInput) -> None:
self.sdk_call(provider_url, case_input)
class _ControlledUpstream(ThreadingHTTPServer):
daemon_threads = True
def __init__(self, stream_chunks: tuple[bytes, ...]) -> None:
super().__init__(("127.0.0.1", 0), _ControlledUpstreamHandler)
self.stream_chunks: Final = stream_chunks
self.lock: Final = threading.Lock()
self.two_requests_started: Final = threading.Event()
self.active_requests: int = 0
self.max_active_requests: int = 0
self.request_count: int = 0
@property
def url(self) -> str:
return f"http://127.0.0.1:{self.server_address[1]}"
def start_request(self) -> None:
with self.lock:
self.active_requests += 1
self.request_count += 1
self.max_active_requests = max(self.max_active_requests, self.active_requests)
if self.active_requests == 2:
self.two_requests_started.set()
self.two_requests_started.wait(timeout=2)
def end_tracked_request(self) -> None:
with self.lock:
self.active_requests -= 1
class _ControlledUpstreamHandler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def do_POST(self) -> None:
upstream: Final = self.server
assert isinstance(upstream, _ControlledUpstream)
length: Final = int(self.headers.get("content-length") or "0")
self.rfile.read(length)
if self.path == "/credentials?api_key=query-secret&api-version=1":
authorized: Final = self.headers.get("authorization") == "Bearer header-secret"
self._send_json(200 if authorized else 401, b"{}")
return
if self.path == "/upload":
self._send_json(200, b'{"file_id":"fixture://document.pdf"}')
return
if self.path == "/parse":
self._send_json(200, b'{"result":{"chunks":[]}}')
return
if self.path == "/analyze":
self.send_response(202)
self.send_header("operation-location", f"{upstream.url}/results/1")
self.send_header("content-length", "0")
self.end_headers()
return
if self.path in {"/v1/chat/completions", "/stream"}:
with upstream.lock:
upstream.request_count += 1
self.send_response(200)
self.send_header("content-type", "text/event-stream")
self.send_header("transfer-encoding", "chunked")
self.end_headers()
for chunk in upstream.stream_chunks:
self.wfile.write(f"{len(chunk):X}\r\n".encode("ascii"))
self.wfile.write(chunk)
self.wfile.write(b"\r\n")
self.wfile.flush()
self.wfile.write(b"0\r\n\r\n")
self.wfile.flush()
return
if self.path == "/error":
self._send_json(429, b'{"error":{"message":"rate limited"}}')
return
upstream.start_request()
try:
body: Final = b"{}"
self.send_response(200)
self.send_header("content-type", "application/json")
self.send_header("set-cookie", "session=must-not-be-recorded")
self.send_header("content-length", str(len(body)))
self.end_headers()
self.wfile.write(body)
finally:
upstream.end_tracked_request()
def do_GET(self) -> None:
if self.path == "/results/1":
self._send_json(200, b'{"status":"succeeded","analyzeResult":{"pages":[]}}')
return
self.send_error(404)
def do_PUT(self) -> None:
self.do_POST()
def do_PATCH(self) -> None:
self.do_POST()
def do_DELETE(self) -> None:
self.do_POST()
def _send_json(self, status: int, body: bytes) -> None:
self.send_response(status)
self.send_header("content-type", "application/json")
self.send_header("content-length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, format: str, *args: object) -> None:
return
@contextmanager
def _controlled_upstream(stream_chunks: tuple[bytes, ...] = _SSE_CHUNKS) -> Generator[_ControlledUpstream]:
server: Final = _ControlledUpstream(stream_chunks)
thread: Final = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
yield server
finally:
server.shutdown()
server.server_close()
thread.join(timeout=5)
def _case(identifier: str) -> _FixtureInput:
return _FixtureInput(identifier=identifier)
def _sdk_call(api_base: str, case_input: _FixtureInput) -> object:
return httpx.post(f"{api_base}/v1/operation", content=b"{}", timeout=5)
def _stream_sdk_call(api_base: str, case_input: _FixtureInput) -> object:
return httpx.post(f"{api_base}/v1/chat/completions", content=b"{}", timeout=5)
def _error_sdk_call(api_base: str, case_input: _FixtureInput) -> object:
response: Final = httpx.post(f"{api_base}/error", content=b"{}", timeout=5)
response.raise_for_status()
return response
def _method_sdk_call(method: str) -> Callable[[str, _FixtureInput], object]:
def call(api_base: str, case_input: _FixtureInput) -> object:
return httpx.request(method, f"{api_base}/method", json={"id": case_input.identifier}, timeout=5)
return call
def _multi_sdk_call(api_base: str, case_input: _FixtureInput) -> object:
upload: Final = httpx.post(f"{api_base}/upload", json={"document": case_input.identifier}, timeout=5)
upload.raise_for_status()
parsed: Final = httpx.post(f"{api_base}/parse", json={"input": upload.json()["file_id"]}, timeout=5)
parsed.raise_for_status()
return parsed
def _polling_sdk_call(api_base: str, case_input: _FixtureInput) -> object:
started: Final = httpx.post(f"{api_base}/analyze", json={"document": case_input.identifier}, timeout=5)
operation_location: Final = started.headers["operation-location"]
completed: Final = httpx.get(operation_location, timeout=5)
completed.raise_for_status()
return completed
def test_recording_deduplicates_per_target_and_caps_global_concurrency(tmp_path: Path) -> None:
shared_input: Final = _case("shared")
with _controlled_upstream() as upstream:
spec: Final = UpstreamEndpoint(base_url=upstream.url)
targets: Final = (
RecordingTarget(
name="first",
upstream=spec,
strategy=st.just(shared_input),
invocation=_Invocation(_sdk_call),
required_inputs=(shared_input, shared_input),
),
RecordingTarget(
name="second",
upstream=spec,
strategy=st.just(shared_input),
invocation=_Invocation(_sdk_call),
required_inputs=(shared_input,),
),
)
summary: Final = record_fixtures(targets, tmp_path, examples=1, concurrency=2, case_type=_ParityCase)
assert len(summary.recorded) == 2
assert {result.target_name for result in summary.recorded} == {"first", "second"}
assert summary.cached == ()
assert summary.failed == ()
assert upstream.request_count == 2
assert upstream.max_active_requests == 2
assert len(recorded_fixtures(tmp_path, _ParityCase)) == 2
for path in tmp_path.rglob("*.yaml"):
contents = path.read_text(encoding="utf-8")
assert f"schema_version: {FIXTURE_SCHEMA_VERSION}" in contents
assert "recorded_at:" in contents
def test_pipeline_rejects_stale_fixture_before_provider_call(tmp_path: Path) -> None:
case_input: Final = _case("stale")
directory: Final = tmp_path / "stale-target"
directory.mkdir()
path: Final = fixture_path(directory, case_input).with_suffix(".json")
path.write_text('{"schema_version": 0}\n', encoding="utf-8")
target: Final = RecordingTarget(
name="stale-target",
upstream=UpstreamEndpoint(base_url="http://127.0.0.1:1"),
strategy=st.just(case_input),
invocation=_Invocation(_sdk_call),
)
summary: Final = record_fixtures(
(target,),
tmp_path,
examples=1,
concurrency=1,
case_type=_ParityCase,
)
assert summary.recorded == ()
assert summary.cached == ()
assert len(summary.failed) == 1
assert str(summary.failed[0].error) == (
f"fixture {path} has schema_version 0, expected {FIXTURE_SCHEMA_VERSION}; "
"delete it and regenerate the fixture bundle"
)
def test_cached_fixture_is_reported_without_provider_call(tmp_path: Path) -> None:
case_input: Final = _case("cached")
with _controlled_upstream() as upstream:
target: Final = RecordingTarget(
name="cached-target",
upstream=UpstreamEndpoint(base_url=upstream.url),
strategy=st.just(case_input),
invocation=_Invocation(_sdk_call),
)
first: Final = record_fixtures((target,), tmp_path, 1, 1, _ParityCase)
second: Final = record_fixtures((target,), tmp_path, 1, 1, _ParityCase)
assert len(first.recorded) == 1
assert len(second.cached) == 1
assert upstream.request_count == 1
def test_streaming_response_records_and_replays_chunks() -> None:
with _controlled_upstream() as upstream:
responses: Final = record_upstream_responses(
UpstreamEndpoint(base_url=upstream.url),
_case("stream"),
_stream_sdk_call,
)
response: Final = responses[0]
assert isinstance(response, RecordedHttpStreamResponse)
assert tuple(chunk.data_bytes() for chunk in response.chunks) == _SSE_CHUNKS
assert isinstance(response.model_dump(mode="json")["chunks"], list)
with replay_server() as provider:
provider.enqueue_response(response)
with httpx.stream("POST", f"{provider.url}/v1/chat/completions", json={}) as replayed:
replayed_chunks: Final = tuple(replayed.iter_raw())
provider.take_requests(1)
assert replayed_chunks == _SSE_CHUNKS
def test_non_successful_provider_response_is_recorded() -> None:
with _controlled_upstream() as upstream:
responses: Final = record_upstream_responses(
UpstreamEndpoint(base_url=upstream.url),
_case("provider-error"),
_error_sdk_call,
)
response: Final = responses[0]
assert response.status_code == 429
def test_sensitive_response_headers_are_not_recorded() -> None:
with _controlled_upstream() as upstream:
responses: Final = record_upstream_responses(
UpstreamEndpoint(base_url=upstream.url),
_case("headers"),
_sdk_call,
)
assert all(header.name.lower() != "set-cookie" for header in responses[0].headers)
def test_recorded_requests_strip_credentials_without_changing_the_live_request() -> None:
def sdk_call(api_base: str, case_input: _FixtureInput) -> object:
return httpx.post(
f"{api_base}/credentials?api_key=query-secret&api-version=1",
headers={
"Authorization": "Bearer header-secret",
"Ocp-Apim-Subscription-Key": "azure-secret",
"Cookie": "session=cookie-secret",
"X-Test": case_input.identifier,
},
content=b"\xffdocument",
)
with _controlled_upstream() as upstream:
interactions: Final = record_upstream_interactions(
UpstreamEndpoint(upstream.url), _case("credentials"), sdk_call
)
interaction: Final = interactions[0]
assert interaction.response.status_code == 200
assert interaction.request.uri == "http://parity-provider.invalid/credentials?api-version=1"
assert interaction.request.body == b"\xffdocument"
assert interaction.request.headers["x-test"] == "credentials"
assert all(
header not in interaction.request.headers for header in ("authorization", "ocp-apim-subscription-key", "cookie")
)
@pytest.mark.parametrize("method", ("PUT", "PATCH", "DELETE"))
def test_recording_and_replay_support_mutating_http_methods(method: str) -> None:
sdk_call: Final = _method_sdk_call(method)
with _controlled_upstream() as upstream:
responses: Final = record_upstream_responses(
UpstreamEndpoint(base_url=upstream.url),
_case(method),
sdk_call,
)
with replay_server() as provider:
provider.enqueue_response(responses[0])
sdk_call(provider.url, _case(method))
requests: Final = provider.take_requests(1)
assert requests[0].method == method
def test_stream_response_model_rejects_buffered_body() -> None:
with pytest.raises(ValueError, match="Extra inputs are not permitted"):
RecordedHttpStreamResponse.model_validate(
{
"kind": "http_stream",
"status_code": 200,
"headers": [HttpHeader(name="content-type", value="text/event-stream")],
"chunks": [RecordedStreamChunk.from_bytes(b"data: [DONE]\n\n")],
"body_b64": "",
}
)
@pytest.mark.parametrize("sdk_call", (_multi_sdk_call, _polling_sdk_call))
def test_multiple_provider_calls_record_and_replay_in_order(
sdk_call: Callable[[str, _FixtureInput], object],
) -> None:
with _controlled_upstream() as upstream:
responses: Final = record_upstream_responses(
UpstreamEndpoint(base_url=upstream.url),
_case(sdk_call.__name__),
sdk_call,
)
assert len(responses) == 2
with replay_server() as provider:
for response in responses:
provider.enqueue_response(response)
sdk_call(provider.url, _case(sdk_call.__name__))
requests: Final = provider.take_requests(2)
assert len(requests) == 2
@pytest.mark.asyncio
@pytest.mark.parametrize("mode", ("sync", "async"))
@pytest.mark.parametrize("failed", (False, True), ids=("completed", "application-error"))
async def test_typed_stream_recording_cassette_replay_parity(
tmp_path: Path, mode: Literal["sync", "async"], failed: bool
) -> None:
case_input: Final = _case("typed-stream")
outcomes: Final[queue.SimpleQueue[StreamOutcome]] = queue.SimpleQueue()
def record(api_base: str, sdk_input: _FixtureInput) -> None:
outcome: Final = (
consume_sync_stream(lambda: _sync_events(api_base, sdk_input))
if mode == "sync"
else asyncio.run(_consume_async_events(api_base, sdk_input))
)
outcomes.put(outcome)
with _controlled_upstream(_event_chunks(failed)) as upstream:
target: Final = RecordingTarget(
name="stream",
upstream=UpstreamEndpoint(upstream.url),
strategy=st.just(case_input),
invocation=_Invocation(record),
)
summary: Final = record_fixtures((target,), tmp_path, 1, 1, _ParityCase)
assert summary.failed == ()
assert len(summary.recorded) == 1
recorded: Final = outcomes.get_nowait()
loaded: Final = load_fixture(tmp_path / "stream", case_input, _ParityCase)
assert loaded is not None
response: Final = loaded.provider_responses[0]
assert isinstance(response, RecordedHttpStreamResponse)
assert response.status_code == 200
wire_bytes: Final = b"".join(chunk.data_bytes() for chunk in response.chunks)
assert wire_bytes == b"".join(_event_chunks(failed))
coalesced: Final = response.model_copy(update={"chunks": (RecordedStreamChunk.from_bytes(wire_bytes),)})
with replay_server() as provider:
first: Final = await _replay_events(mode, provider, response, case_input)
second: Final = await _replay_events(mode, provider, coalesced, case_input)
assert_request_parity(first.requests, second.requests)
assert len(first.requests) == 1
assert first.requests[0].body == {"id": case_input.identifier}
assert_stream_parity(recorded, first.response)
assert_stream_parity(first.response, second.response)
expected: Final = (_StreamEvent(kind="delta", value="café"), _StreamEvent(kind="delta", value="second"))
assert first.response.chunks == (expected if failed else (*expected, _StreamEvent(kind="done", value="")))
if failed:
assert isinstance(first.response.terminal, StreamFailed)
assert first.response.terminal.phase == "iteration"
assert first.response.terminal.exception_type is _StreamApplicationError
assert first.response.terminal.error.code == "invalid_input"
assert first.response.terminal.error.message == "invalid input"
else:
assert first.response.terminal == StreamCompleted()

View file

@ -0,0 +1,47 @@
from __future__ import annotations
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Final, Generic, TypeVar
from tests.route_parity.models import CapturedRequest
from tests.route_parity.recorded_http import RecordedResponse
from tests.route_parity.replay import ReplayServer
ResponseT = TypeVar("ResponseT")
@dataclass(frozen=True, slots=True)
class InProcessExecution(Generic[ResponseT]):
requests: tuple[CapturedRequest, ...]
response: ResponseT
def run_in_process(
provider: ReplayServer,
recorded_responses: tuple[RecordedResponse, ...],
call: Callable[[str], ResponseT],
) -> InProcessExecution[ResponseT]:
for recorded_response in recorded_responses:
provider.enqueue_response(recorded_response)
try:
response: Final = call(provider.url)
return InProcessExecution(requests=provider.take_requests(len(recorded_responses)), response=response)
except Exception:
provider.reset()
raise
async def run_in_process_async(
provider: ReplayServer,
recorded_responses: tuple[RecordedResponse, ...],
call: Callable[[str], Awaitable[ResponseT]],
) -> InProcessExecution[ResponseT]:
for recorded_response in recorded_responses:
provider.enqueue_response(recorded_response)
try:
response: Final = await call(provider.url)
return InProcessExecution(requests=provider.take_requests(len(recorded_responses)), response=response)
except Exception:
provider.reset()
raise

View file

@ -0,0 +1,145 @@
from __future__ import annotations
import base64
from typing import Annotated, Final, Literal, cast
from pydantic import BaseModel, ConfigDict, Field, JsonValue, TypeAdapter
class CapturedRequest(BaseModel):
model_config = ConfigDict(frozen=True)
method: str
path: str
headers: tuple[tuple[str, str], ...]
body: JsonValue
user_agent: str | None
class SDKSuccess(BaseModel):
model_config = ConfigDict(frozen=True)
status: Literal["ok"] = "ok"
response: JsonValue
class SDKError(BaseModel):
model_config = ConfigDict(frozen=True)
status: Literal["error"] = "error"
exception_type: str
message: str
status_code: int | None
code: str | None
error_type: str | None
param: str | None
model: str | None
llm_provider: str | None
class SDKJsonChunk(BaseModel):
model_config = ConfigDict(frozen=True)
kind: Literal["json"] = "json"
value: JsonValue
class SDKBytesChunk(BaseModel):
model_config = ConfigDict(frozen=True)
kind: Literal["bytes"] = "bytes"
data_b64: str
def data_bytes(self) -> bytes:
return base64.b64decode(self.data_b64, validate=True)
SDKChunk = Annotated[SDKJsonChunk | SDKBytesChunk, Field(discriminator="kind")]
class SDKStreamCompleted(BaseModel):
model_config = ConfigDict(frozen=True)
kind: Literal["completed"] = "completed"
class SDKStreamFailed(BaseModel):
model_config = ConfigDict(frozen=True)
kind: Literal["failed"] = "failed"
error: SDKError
SDKStreamTerminal = Annotated[SDKStreamCompleted | SDKStreamFailed, Field(discriminator="kind")]
class SDKStreamReport(BaseModel):
model_config = ConfigDict(frozen=True)
status: Literal["stream"] = "stream"
chunks: tuple[SDKChunk, ...]
terminal: SDKStreamTerminal
SDKReport = Annotated[SDKSuccess | SDKError | SDKStreamReport, Field(discriminator="status")]
JSON_VALUE_ADAPTER: Final[TypeAdapter[JsonValue]] = TypeAdapter(JsonValue)
def sdk_chunk(value: object) -> SDKChunk:
if isinstance(value, bytes):
return SDKBytesChunk(data_b64=base64.b64encode(value).decode("ascii"))
if isinstance(value, BaseModel):
return SDKJsonChunk(value=JSON_VALUE_ADAPTER.validate_python(value.model_dump(mode="json")))
return SDKJsonChunk(value=JSON_VALUE_ADAPTER.validate_python(value))
def _string_attribute(error: Exception, name: str) -> str | None:
value: Final = cast(object | None, getattr(error, name, None))
return None if value is None else str(value)
def sdk_error_report(error: Exception) -> SDKError:
message, _, _ = str(error).partition("\nTraceback (most recent call last):")
raw_status_code: Final = cast(object | None, getattr(error, "status_code", None))
status_code: Final = raw_status_code if isinstance(raw_status_code, int) else None
return SDKError(
exception_type=f"{type(error).__module__}.{type(error).__qualname__}",
message=message.rstrip(),
status_code=status_code,
code=_string_attribute(error, "code"),
error_type=_string_attribute(error, "type"),
param=_string_attribute(error, "param"),
model=_string_attribute(error, "model"),
llm_provider=_string_attribute(error, "llm_provider"),
)
class Execution(BaseModel):
model_config = ConfigDict(frozen=True)
requests: tuple[CapturedRequest, ...]
report: SDKReport
class SDKCommand(BaseModel):
model_config = ConfigDict(frozen=True)
case_file: str
route: str
class WorkerSuccess(BaseModel):
model_config = ConfigDict(frozen=True)
status: Literal["ok"] = "ok"
report: SDKReport
class WorkerFailure(BaseModel):
model_config = ConfigDict(frozen=True)
status: Literal["error"] = "error"
error: str
WorkerResult = Annotated[WorkerSuccess | WorkerFailure, Field(discriminator="status")]

View file

@ -0,0 +1,63 @@
from __future__ import annotations
import base64
from typing import Annotated, Literal
from pydantic import BaseModel, ConfigDict, Field
class _RecordedHttpModel(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
class HttpHeader(_RecordedHttpModel):
name: str
value: str
class RecordedHttpResponse(_RecordedHttpModel):
kind: Literal["http"]
status_code: int
headers: tuple[HttpHeader, ...]
body_b64: str
@classmethod
def from_bytes(
cls,
status_code: int,
headers: tuple[HttpHeader, ...],
body: bytes,
) -> RecordedHttpResponse:
return cls(
kind="http",
status_code=status_code,
headers=headers,
body_b64=base64.b64encode(body).decode("ascii"),
)
def body_bytes(self) -> bytes:
return base64.b64decode(self.body_b64, validate=True)
class RecordedStreamChunk(_RecordedHttpModel):
data_b64: str
@classmethod
def from_bytes(cls, data: bytes) -> RecordedStreamChunk:
return cls(data_b64=base64.b64encode(data).decode("ascii"))
def data_bytes(self) -> bytes:
return base64.b64decode(self.data_b64, validate=True)
class RecordedHttpStreamResponse(_RecordedHttpModel):
kind: Literal["http_stream"]
status_code: int
headers: tuple[HttpHeader, ...]
chunks: tuple[RecordedStreamChunk, ...]
RecordedResponse = Annotated[
RecordedHttpResponse | RecordedHttpStreamResponse,
Field(discriminator="kind"),
]

View file

@ -0,0 +1,147 @@
from __future__ import annotations
import base64
import queue
import threading
from collections.abc import Generator
from contextlib import contextmanager
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Final
from pydantic import JsonValue, TypeAdapter
from tests.route_parity.fixtures.recording import local_response_header
from tests.route_parity.models import CapturedRequest
from tests.route_parity.recorded_http import RecordedHttpResponse, RecordedHttpStreamResponse, RecordedResponse
JSON_VALUE: Final[TypeAdapter[JsonValue]] = TypeAdapter(JsonValue)
EXCLUDED_REQUEST_HEADERS: Final = frozenset(
{
"host",
"content-length",
"connection",
"accept-encoding",
"user-agent",
"x-litellm-parity-route",
}
)
EXCLUDED_RESPONSE_HEADERS: Final = frozenset({"content-length", "transfer-encoding", "connection"})
class ReplayServer(ThreadingHTTPServer):
daemon_threads = True
def __init__(self) -> None:
super().__init__(("127.0.0.1", 0), _ReplayHandler)
self.responses: queue.Queue[RecordedResponse] = queue.Queue()
self.requests: queue.Queue[CapturedRequest] = queue.Queue()
@property
def url(self) -> str:
return f"http://127.0.0.1:{self.server_address[1]}"
def enqueue_response(self, response: RecordedResponse) -> None:
self.responses.put(response)
def take_requests(self, expected_count: int) -> tuple[CapturedRequest, ...]:
request_count: Final = self.requests.qsize()
if request_count != expected_count:
raise AssertionError(f"expected exactly {expected_count} provider requests, received {request_count}")
return tuple(self.requests.get_nowait() for _ in range(request_count))
def reset(self) -> None:
while not self.responses.empty():
self.responses.get_nowait()
while not self.requests.empty():
self.requests.get_nowait()
class _ReplayHandler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def do_POST(self) -> None:
self._replay()
def do_GET(self) -> None:
self._replay()
def do_PUT(self) -> None:
self._replay()
def do_PATCH(self) -> None:
self._replay()
def do_DELETE(self) -> None:
self._replay()
def _replay(self) -> None:
provider: Final = self.server
assert isinstance(provider, ReplayServer)
length: Final = int(self.headers.get("content-length") or "0")
raw_body: Final = self.rfile.read(length) if length else b""
content_type: Final = self.headers.get("content-type", "")
body: Final = (
JSON_VALUE.validate_json(raw_body)
if raw_body and content_type.lower().startswith("application/json")
else base64.b64encode(raw_body).decode("ascii")
if raw_body
else None
)
headers: Final = tuple(
sorted(
(name.lower(), value)
for name, value in self.headers.raw_items()
if name.lower() not in EXCLUDED_REQUEST_HEADERS
)
)
provider.requests.put(
CapturedRequest(
method=self.command,
path=self.path,
headers=headers,
body=body,
user_agent=self.headers.get("user-agent"),
)
)
try:
response: Final = provider.responses.get(timeout=5)
except queue.Empty:
self.send_error(500, "no replay response queued")
return
self.send_response_only(response.status_code)
for header in response.headers:
if header.name.lower() not in EXCLUDED_RESPONSE_HEADERS:
self.send_header(header.name, local_response_header(header.name, header.value, provider.url))
if isinstance(response, RecordedHttpResponse):
response_body: Final = response.body_bytes()
self.send_header("content-length", str(len(response_body)))
self.end_headers()
self.wfile.write(response_body)
return
assert isinstance(response, RecordedHttpStreamResponse)
self.send_header("transfer-encoding", "chunked")
self.end_headers()
for chunk in response.chunks:
data = chunk.data_bytes()
self.wfile.write(f"{len(data):X}\r\n".encode("ascii"))
self.wfile.write(data)
self.wfile.write(b"\r\n")
self.wfile.flush()
self.wfile.write(b"0\r\n\r\n")
self.wfile.flush()
def log_message(self, format: str, *args: object) -> None:
return
@contextmanager
def replay_server() -> Generator[ReplayServer]:
server: Final = ReplayServer()
thread: Final = threading.Thread(target=server.serve_forever, kwargs={"poll_interval": 0.01}, daemon=True)
thread.start()
try:
yield server
finally:
server.shutdown()
server.server_close()
thread.join(timeout=5)

View file

@ -0,0 +1,195 @@
from __future__ import annotations
import asyncio
import os
import subprocess
import sys
from collections import deque
from collections.abc import Callable, Generator
from concurrent.futures import ThreadPoolExecutor, TimeoutError
from contextlib import contextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import Final, TextIO, cast
from pydantic import TypeAdapter, ValidationError
from tests.route_parity.models import (
Execution,
SDKCommand,
WorkerFailure,
WorkerResult,
WorkerSuccess,
)
from tests.route_parity.recorded_http import RecordedResponse
from tests.route_parity.replay import ReplayServer, replay_server
WORKER_RESULT_PREFIX: Final = "LITELLM_PARITY_RESULT "
WORKER_RESULT_ADAPTER: Final[TypeAdapter[WorkerResult]] = TypeAdapter(WorkerResult)
@dataclass(frozen=True, slots=True)
class SubprocessRunner:
entrypoint: Path
baseline_user_agent: str
route_label: str
def command(self, provider_url: str) -> tuple[str, ...]:
return (
sys.executable,
str(self.entrypoint.resolve()),
"--parity-worker",
provider_url,
)
@dataclass(frozen=True, slots=True)
class ExecutionVariant:
name: str
environment: tuple[tuple[str, str], ...]
class SubprocessWorker:
def __init__(self, runner: SubprocessRunner, provider: ReplayServer, variant: ExecutionVariant) -> None:
project_root: Final = str(runner.entrypoint.resolve().parents[3])
existing_pythonpath: Final = os.environ.get("PYTHONPATH")
env: Final = {
**os.environ,
**dict(variant.environment),
"LITELLM_USER_AGENT": runner.baseline_user_agent,
"PYTHONPATH": os.pathsep.join(path for path in (project_root, existing_pythonpath) if path),
}
self.mode: Final = variant.name
self.route_label: Final = runner.route_label
self.provider: Final = provider
self.process: Final = subprocess.Popen(
runner.command(provider.url),
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
env=env,
)
self.output_reader: Final = ThreadPoolExecutor(max_workers=1)
self.recent_output: Final[deque[str]] = deque(maxlen=100)
def execute(
self,
case_file: Path,
route: str,
responses: tuple[RecordedResponse, ...],
) -> Execution:
stdin: Final = self.process.stdin
if stdin is None or self.process.poll() is not None:
raise AssertionError(f"{self.mode} {self.route_label} worker exited before processing {case_file}")
for response in responses:
self.provider.enqueue_response(response)
command: Final = SDKCommand(case_file=str(case_file), route=route)
try:
stdin.write(f"{command.model_dump_json()}\n")
stdin.flush()
result: Final = self.output_reader.submit(self._read_result).result(timeout=60)
except TimeoutError as error:
self.provider.reset()
self.close()
raise AssertionError(
f"{self.mode} {self.route_label} worker timed out after 60s while processing {case_file}"
) from error
except AssertionError:
self.provider.reset()
raise
except (BrokenPipeError, OSError) as error:
self.provider.reset()
raise AssertionError(self._failure_message(f"worker pipe failed while processing {case_file}")) from error
if isinstance(result, WorkerFailure):
self.provider.reset()
raise AssertionError(
f"{self.mode} {self.route_label} worker failed while processing {case_file}:\n{result.error}"
)
assert isinstance(result, WorkerSuccess)
try:
return Execution(requests=self.provider.take_requests(len(responses)), report=result.report)
except AssertionError:
self.provider.reset()
raise
def _read_result(self) -> WorkerResult:
process_stdout: Final = self.process.stdout
if process_stdout is None:
raise AssertionError(self._failure_message("worker stdout is unavailable"))
stdout: Final = cast(TextIO, process_stdout)
line: Final = stdout.readline()
if not line:
raise AssertionError(self._failure_message("worker exited without returning a result"))
stripped: Final = line.rstrip()
if not stripped.startswith(WORKER_RESULT_PREFIX):
self.recent_output.append(stripped)
return self._read_result()
payload: Final = stripped.removeprefix(WORKER_RESULT_PREFIX)
try:
return WORKER_RESULT_ADAPTER.validate_json(payload)
except ValidationError as error:
raise AssertionError(self._failure_message("worker returned an invalid result")) from error
def _failure_message(self, message: str) -> str:
output: Final = "\n".join(self.recent_output)
prefix: Final = f"{self.mode} {self.route_label}"
return f"{prefix} {message}" if not output else f"{prefix} {message}\noutput:\n{output}"
def close(self) -> None:
stdin: Final = self.process.stdin
if stdin is not None and not stdin.closed:
stdin.close()
try:
self.process.wait(timeout=10)
except subprocess.TimeoutExpired:
self.process.terminate()
self.process.wait(timeout=10)
self.output_reader.shutdown(wait=True, cancel_futures=True)
@contextmanager
def execution_worker(
runner: SubprocessRunner,
variant: ExecutionVariant,
) -> Generator[SubprocessWorker]:
with replay_server() as provider:
worker: Final = SubprocessWorker(runner, provider, variant)
try:
yield worker
finally:
worker.close()
def run_execution(
worker: SubprocessWorker,
case_file: Path,
route: str,
responses: tuple[RecordedResponse, ...],
) -> Execution:
return worker.execute(case_file, route, responses)
@contextmanager
def execution_worker_pair(
runner: SubprocessRunner,
baseline: ExecutionVariant,
candidate: ExecutionVariant,
) -> Generator[tuple[SubprocessWorker, SubprocessWorker]]:
with execution_worker(runner, baseline) as baseline_worker:
with execution_worker(runner, candidate) as candidate_worker:
yield baseline_worker, candidate_worker
def parity_worker_main(
execute_command: Callable[[str, str, asyncio.AbstractEventLoop], WorkerResult],
mock_url: str,
) -> None:
event_loop: Final = asyncio.new_event_loop()
try:
for line in sys.stdin:
sys.stdout.write(f"{WORKER_RESULT_PREFIX}{execute_command(line, mock_url, event_loop).model_dump_json()}\n")
sys.stdout.flush()
finally:
event_loop.close()

View file

@ -0,0 +1,177 @@
from __future__ import annotations
from collections.abc import AsyncIterable, Awaitable, Callable, Iterable
from dataclasses import dataclass
from typing import Final, Literal, TypeAlias
from tests.route_parity.compare import assert_value_parity
from tests.route_parity.models import (
SDKError,
SDKReport,
SDKStreamCompleted,
SDKStreamFailed,
SDKStreamReport,
sdk_chunk,
sdk_error_report,
)
@dataclass(frozen=True, slots=True)
class StreamCompleted:
kind: Literal["completed"] = "completed"
@dataclass(frozen=True, slots=True)
class StreamFailed:
phase: Literal["creation", "iteration"]
exception_type: type[BaseException]
error: SDKError
kind: Literal["failed"] = "failed"
StreamTerminal: TypeAlias = StreamCompleted | StreamFailed
@dataclass(frozen=True, slots=True)
class StreamOutcome:
wrapper_type: type[object] | None
supports_sync_iteration: bool | None
supports_async_iteration: bool | None
chunks: tuple[object, ...]
chunk_types: tuple[type[object], ...]
terminal: StreamTerminal
ChunkNormalizer: TypeAlias = Callable[[object], object]
def drain_sync_stream(stream: Iterable[object]) -> None:
for _ in stream:
pass
async def drain_async_stream(stream: AsyncIterable[object]) -> None:
async for _ in stream:
pass
def capture_sync_stream(create: Callable[[], Iterable[object]]) -> SDKReport:
return _stream_report(consume_sync_stream(create))
async def capture_async_stream(create: Callable[[], Awaitable[AsyncIterable[object]]]) -> SDKReport:
return _stream_report(await consume_async_stream(create))
def _stream_report(outcome: StreamOutcome) -> SDKReport:
terminal: Final = outcome.terminal
if isinstance(terminal, StreamFailed) and terminal.phase == "creation":
return terminal.error
return SDKStreamReport(
chunks=tuple(sdk_chunk(chunk) for chunk in outcome.chunks),
terminal=SDKStreamFailed(error=terminal.error) if isinstance(terminal, StreamFailed) else SDKStreamCompleted(),
)
def _failed(phase: Literal["creation", "iteration"], error: Exception) -> StreamFailed:
return StreamFailed(
phase=phase,
exception_type=type(error),
error=sdk_error_report(error),
)
def consume_sync_stream(create: Callable[[], Iterable[object]]) -> StreamOutcome:
try:
stream: Final = create()
except Exception as error:
return StreamOutcome(
wrapper_type=None,
supports_sync_iteration=None,
supports_async_iteration=None,
chunks=(),
chunk_types=(),
terminal=_failed("creation", error),
)
chunks: list[object] = [] # mutable-ok: iterator consumption builds an ordered trace
try:
for chunk in stream:
chunks.append(chunk) # noqa: PERF402 # partial trace is required if iteration raises
except Exception as error:
recorded: Final = tuple(chunks)
return StreamOutcome(
wrapper_type=type(stream),
supports_sync_iteration=hasattr(stream, "__iter__"),
supports_async_iteration=hasattr(stream, "__aiter__"),
chunks=recorded,
chunk_types=tuple(type(chunk) for chunk in recorded),
terminal=_failed("iteration", error),
)
completed_chunks: Final = tuple(chunks)
return StreamOutcome(
wrapper_type=type(stream),
supports_sync_iteration=hasattr(stream, "__iter__"),
supports_async_iteration=hasattr(stream, "__aiter__"),
chunks=completed_chunks,
chunk_types=tuple(type(chunk) for chunk in completed_chunks),
terminal=StreamCompleted(),
)
async def consume_async_stream(create: Callable[[], Awaitable[AsyncIterable[object]]]) -> StreamOutcome:
try:
stream: Final = await create()
except Exception as error:
return StreamOutcome(
wrapper_type=None,
supports_sync_iteration=None,
supports_async_iteration=None,
chunks=(),
chunk_types=(),
terminal=_failed("creation", error),
)
chunks: list[object] = [] # mutable-ok: iterator consumption builds an ordered trace
try:
async for chunk in stream:
chunks.append(chunk)
except Exception as error:
recorded: Final = tuple(chunks)
return StreamOutcome(
wrapper_type=type(stream),
supports_sync_iteration=hasattr(stream, "__iter__"),
supports_async_iteration=hasattr(stream, "__aiter__"),
chunks=recorded,
chunk_types=tuple(type(chunk) for chunk in recorded),
terminal=_failed("iteration", error),
)
completed_chunks: Final = tuple(chunks)
return StreamOutcome(
wrapper_type=type(stream),
supports_sync_iteration=hasattr(stream, "__iter__"),
supports_async_iteration=hasattr(stream, "__aiter__"),
chunks=completed_chunks,
chunk_types=tuple(type(chunk) for chunk in completed_chunks),
terminal=StreamCompleted(),
)
def normalize_chunk(chunk: object) -> object:
return chunk
def assert_stream_parity(
baseline: StreamOutcome,
candidate: StreamOutcome,
*,
normalize: ChunkNormalizer = normalize_chunk,
) -> None:
assert baseline.wrapper_type is candidate.wrapper_type
assert baseline.supports_sync_iteration is candidate.supports_sync_iteration
assert baseline.supports_async_iteration is candidate.supports_async_iteration
assert baseline.chunk_types == candidate.chunk_types
assert len(baseline.chunks) == len(candidate.chunks)
for index, (baseline_chunk, candidate_chunk) in enumerate(zip(baseline.chunks, candidate.chunks, strict=True)):
assert_value_parity(normalize(baseline_chunk), normalize(candidate_chunk), path=f"$.chunks[{index}]")
assert baseline.terminal == candidate.terminal

View file

@ -0,0 +1,190 @@
from __future__ import annotations
from types import MappingProxyType
from typing import Final
import pytest
from pydantic import BaseModel, ConfigDict, JsonValue, PrivateAttr
from tests.route_parity.compare import assert_model_parity, assert_parity
from tests.route_parity.models import CapturedRequest, Execution, SDKError, SDKSuccess, sdk_error_report
SENTINEL: Final = "python-parity-fallback"
class _ComparableResponse(BaseModel):
value: str
_hidden_params: dict[str, object] = PrivateAttr(default_factory=dict)
def set_hidden_param(self, key: str, value: object) -> None:
self._hidden_params[key] = value
class _DifferentResponse(BaseModel):
value: str
class _FloatResponse(BaseModel):
values: list[float]
class _PublicValue(BaseModel):
model_config = ConfigDict(extra="allow")
value: object
class _PublicError(ValueError):
status_code: Final = 400
def _execution(*, body: JsonValue = None, markdown: str = "same", user_agent: str | None = None) -> Execution:
return Execution(
requests=(
CapturedRequest(
method="POST",
path="/v1/test-route?mode=test",
headers=(("authorization", "Bearer test-key"), ("content-type", "application/json")),
body={"model": "test-model"} if body is None else body,
user_agent=user_agent,
),
),
report=SDKSuccess(response={"items": [{"text": markdown}], "model": "test-model"}),
)
def test_parity_rejects_request_difference() -> None:
python: Final = _execution(user_agent=SENTINEL)
rust: Final = _execution(body={"model": "different"}, user_agent="litellm-rust")
with pytest.raises(AssertionError):
assert_parity(python, rust, SENTINEL)
def test_parity_rejects_response_difference() -> None:
python: Final = _execution(user_agent=SENTINEL)
rust: Final = _execution(markdown="different", user_agent="litellm-rust")
with pytest.raises(AssertionError):
assert_parity(python, rust, SENTINEL)
def test_parity_rejects_error_difference() -> None:
python: Final = Execution(
requests=(),
report=SDKError(
exception_type="litellm.exceptions.BadRequestError",
message="bad request",
status_code=400,
code=None,
error_type=None,
param=None,
model="test-model",
llm_provider="test-provider",
),
)
rust: Final = python.model_copy(update={"report": python.report.model_copy(update={"status_code": 500})})
with pytest.raises(AssertionError):
assert_parity(python, rust, SENTINEL)
def test_sdk_error_report_removes_traceback_but_keeps_public_fields() -> None:
error: Final = _PublicError("invalid input\nTraceback (most recent call last):\n unstable")
report: Final = sdk_error_report(error)
assert report.exception_type.endswith("._PublicError")
assert report.message == "invalid input"
assert report.status_code == 400
def test_parity_rejects_rust_fallback() -> None:
python: Final = _execution(user_agent=SENTINEL)
rust: Final = _execution(user_agent=SENTINEL)
with pytest.raises(AssertionError, match="fell back"):
assert_parity(python, rust, SENTINEL)
def test_model_parity_compares_public_values_and_ignores_private_attrs() -> None:
python: Final = _ComparableResponse(value="same")
rust: Final = _ComparableResponse(value="same")
python.set_hidden_param("litellm_call_id", "python-id")
rust.set_hidden_param("litellm_call_id", "rust-id")
assert_model_parity(python, rust)
def test_model_parity_rejects_public_value_difference() -> None:
python: Final = _ComparableResponse(value="python")
rust: Final = _ComparableResponse(value="rust")
with pytest.raises(AssertionError):
assert_model_parity(python, rust)
def test_model_parity_rejects_type_difference() -> None:
with pytest.raises(AssertionError):
assert_model_parity(_ComparableResponse(value="same"), _DifferentResponse(value="same"))
def test_model_parity_rejects_wire_float_rounding_difference() -> None:
with pytest.raises(AssertionError, match=r"\$\.values\[0\]"):
assert_model_parity(
_FloatResponse(values=[0.22590550796036835]),
_FloatResponse(values=[0.22590550796036837]),
)
def test_model_parity_rejects_meaningful_float_difference() -> None:
with pytest.raises(AssertionError, match=r"\$\.values\[0\]"):
assert_model_parity(
_FloatResponse(values=[0.22590550796036835]),
_FloatResponse(values=[0.2259]),
)
@pytest.mark.parametrize(
("baseline", "candidate"),
(
(_ComparableResponse(value="same"), {"value": "same"}),
(_ComparableResponse(value="same"), _DifferentResponse(value="same")),
(True, 1),
(1, 1.0),
(["same"], ("same",)),
({"value": "same"}, MappingProxyType({"value": "same"})),
({True: "same"}, {1: "same"}),
),
ids=("model-dict", "model-class", "bool-int", "int-float", "list-tuple", "mapping-class", "key-type"),
)
def test_model_parity_rejects_nested_type_changes(baseline: object, candidate: object) -> None:
with pytest.raises(AssertionError, match=r"\$\.value\[0\]"):
assert_model_parity(_PublicValue(value=[baseline]), _PublicValue(value=[candidate]))
def test_model_parity_ignores_nested_private_attributes() -> None:
baseline: Final = _ComparableResponse(value="same")
candidate: Final = _ComparableResponse(value="same")
baseline.set_hidden_param("request_id", "baseline")
candidate.set_hidden_param("request_id", "candidate")
assert_model_parity(_PublicValue(value={"nested": [baseline]}), _PublicValue(value={"nested": [candidate]}))
@pytest.mark.parametrize("extras", ({"provider_value": "changed"}, {}, {"provider_value": {"value": "same"}}))
def test_model_parity_compares_public_extras(extras: dict[str, object]) -> None:
baseline: Final = _PublicValue.model_validate({"value": None, "provider_value": _ComparableResponse(value="same")})
candidate: Final = _PublicValue.model_validate({"value": None, **extras})
with pytest.raises(AssertionError):
assert_model_parity(baseline, candidate)
def test_serialized_parity_rejects_boolean_integer_substitution() -> None:
with pytest.raises(AssertionError, match="type mismatch"):
assert_parity(
_execution(body={"enabled": True}, user_agent=SENTINEL),
_execution(body={"enabled": 1}, user_agent="candidate"),
SENTINEL,
)

View file

@ -0,0 +1,344 @@
from __future__ import annotations
import queue
from collections.abc import AsyncIterator, Iterator
from typing import Final, Literal, NoReturn
import pytest
from pydantic import BaseModel, PrivateAttr, ValidationError
from tests.route_parity.models import (
SDKBytesChunk,
SDKError,
SDKJsonChunk,
SDKReport,
SDKStreamCompleted,
SDKStreamFailed,
SDKStreamReport,
sdk_error_report,
)
from tests.route_parity.stream import (
StreamCompleted,
StreamFailed,
StreamOutcome,
assert_stream_parity,
capture_async_stream,
capture_sync_stream,
consume_async_stream,
consume_sync_stream,
drain_async_stream,
drain_sync_stream,
)
class _Chunk(BaseModel):
value: str
_hidden_params: dict[str, object] = PrivateAttr(default_factory=dict)
def set_hidden_param(self, key: str, value: object) -> None:
self._hidden_params[key] = value
class _NestedChunk(BaseModel):
value: object
class _SyncStream:
def __init__(self, chunks: tuple[object, ...], error: BaseException | None = None) -> None:
self.chunks: Final = chunks
self.error: Final = error
def __iter__(self) -> Iterator[object]:
yield from self.chunks
if self.error is not None:
raise self.error
class _AsyncStream:
def __init__(self, chunks: tuple[object, ...], error: BaseException | None = None) -> None:
self.chunks: Final = chunks
self.error: Final = error
async def __aiter__(self) -> AsyncIterator[object]:
for chunk in self.chunks:
yield chunk
if self.error is not None:
raise self.error
class _PublicStreamError(Exception):
def __init__(
self,
message: str = "invalid input",
*,
status_code: int = 400,
llm_provider: str = "test",
model: str = "test-model",
code: str = "invalid_input",
error_type: str = "validation_error",
param: str = "input",
) -> None:
super().__init__(message)
self.status_code: Final = status_code
self.llm_provider: Final = llm_provider
self.model: Final = model
self.code: Final = code
self.type: Final = error_type
self.param: Final = param
def _creation_error() -> NoReturn:
raise _PublicStreamError(status_code=429, llm_provider="test", model="test-model")
async def _async_stream(chunks: tuple[object, ...], error: BaseException | None = None) -> _AsyncStream:
return _AsyncStream(chunks, error)
async def _consume(
mode: Literal["sync", "async"], chunks: tuple[object, ...], error: Exception | None = None
) -> StreamOutcome:
if mode == "sync":
return consume_sync_stream(lambda: _SyncStream(chunks, error))
return await consume_async_stream(lambda: _async_stream(chunks, error))
async def _capture(
mode: Literal["sync", "async"], chunks: tuple[object, ...], error: Exception | None = None
) -> SDKReport:
if mode == "sync":
return capture_sync_stream(lambda: _SyncStream(chunks, error))
return await capture_async_stream(lambda: _async_stream(chunks, error))
def test_sync_stream_parity_compares_chunks_and_ignores_private_attrs() -> None:
python_chunk: Final = _Chunk(value="same")
accelerated_chunk: Final = _Chunk(value="same")
python_chunk.set_hidden_param("request_id", "python")
accelerated_chunk.set_hidden_param("request_id", "accelerated")
python: Final = consume_sync_stream(lambda: _SyncStream((python_chunk,)))
accelerated: Final = consume_sync_stream(lambda: _SyncStream((accelerated_chunk,)))
assert python.supports_sync_iteration is True
assert python.supports_async_iteration is False
assert_stream_parity(python, accelerated)
def test_stream_parity_rejects_extra_chunk() -> None:
python: Final = consume_sync_stream(lambda: _SyncStream((_Chunk(value="one"),)))
accelerated: Final = consume_sync_stream(lambda: _SyncStream((_Chunk(value="one"), _Chunk(value="two"))))
with pytest.raises(AssertionError):
assert_stream_parity(python, accelerated)
def test_stream_parity_rejects_chunk_value_difference() -> None:
python: Final = consume_sync_stream(lambda: _SyncStream((_Chunk(value="python"),)))
accelerated: Final = consume_sync_stream(lambda: _SyncStream((_Chunk(value="accelerated"),)))
with pytest.raises(AssertionError):
assert_stream_parity(python, accelerated)
def test_stream_outcome_distinguishes_creation_and_iteration_errors() -> None:
creation: Final = consume_sync_stream(_creation_error)
iteration: Final = consume_sync_stream(
lambda: _SyncStream(
(_Chunk(value="before-error"),),
_PublicStreamError(status_code=429, llm_provider="test", model="test-model"),
)
)
assert isinstance(creation.terminal, StreamFailed)
assert creation.terminal.phase == "creation"
assert creation.chunks == ()
assert isinstance(iteration.terminal, StreamFailed)
assert iteration.terminal.phase == "iteration"
assert len(iteration.chunks) == 1
with pytest.raises(AssertionError):
assert_stream_parity(creation, iteration)
@pytest.mark.asyncio
async def test_async_stream_uses_same_trace_contract() -> None:
python_error: Final = _PublicStreamError(
"invalid input\nTraceback (most recent call last):\npython detail", status_code=500
)
accelerated_error: Final = _PublicStreamError(
"invalid input\nTraceback (most recent call last):\nrust detail", status_code=500
)
python: Final = await consume_async_stream(lambda: _async_stream((_Chunk(value="same"),), python_error))
accelerated: Final = await consume_async_stream(lambda: _async_stream((_Chunk(value="same"),), accelerated_error))
assert python.supports_sync_iteration is False
assert python.supports_async_iteration is True
assert_stream_parity(python, accelerated)
def test_stream_parity_accepts_route_specific_chunk_normalizer() -> None:
python: Final = consume_sync_stream(lambda: _SyncStream((_Chunk(value="python-generated-id"),)))
accelerated: Final = consume_sync_stream(lambda: _SyncStream((_Chunk(value="rust-generated-id"),)))
assert_stream_parity(python, accelerated, normalize=lambda chunk: type(chunk))
def test_drain_sync_stream_exhausts_lazy_iterator() -> None:
consumed: Final[queue.SimpleQueue[str]] = queue.SimpleQueue()
def chunks() -> Iterator[object]:
yield _Chunk(value="one")
consumed.put("complete")
drain_sync_stream(chunks())
assert consumed.get_nowait() == "complete"
@pytest.mark.asyncio
async def test_drain_async_stream_exhausts_lazy_iterator() -> None:
consumed: Final[queue.SimpleQueue[str]] = queue.SimpleQueue()
async def chunks() -> AsyncIterator[object]:
yield b"one"
consumed.put("complete")
await drain_async_stream(chunks())
assert consumed.get_nowait() == "complete"
def test_capture_sync_stream_serializes_model_chunks_and_partial_failure() -> None:
report: Final = capture_sync_stream(
lambda: _SyncStream(
(_Chunk(value="before-error"),),
_PublicStreamError(status_code=429, llm_provider="test", model="test-model"),
)
)
assert isinstance(report, SDKStreamReport)
assert len(report.chunks) == 1
chunk: Final = report.chunks[0]
assert isinstance(chunk, SDKJsonChunk)
assert chunk.value == {"value": "before-error"}
assert isinstance(report.terminal, SDKStreamFailed)
assert report.terminal.error.status_code == 429
@pytest.mark.asyncio
async def test_capture_async_stream_serializes_message_bytes_in_order() -> None:
report: Final = await capture_async_stream(lambda: _async_stream((b"first", b"second")))
assert isinstance(report, SDKStreamReport)
assert tuple(chunk.data_bytes() for chunk in report.chunks if isinstance(chunk, SDKBytesChunk)) == (
b"first",
b"second",
)
@pytest.mark.asyncio
@pytest.mark.parametrize("mode", ("sync", "async"))
@pytest.mark.parametrize(
"candidate_error",
(
ValueError("invalid input"),
_PublicStreamError("changed message"),
_PublicStreamError(status_code=429),
_PublicStreamError(code="changed_code"),
_PublicStreamError(error_type="changed_type"),
_PublicStreamError(param="changed_param"),
_PublicStreamError(model="changed_model"),
_PublicStreamError(llm_provider="changed_provider"),
),
ids=("exception", "message", "status", "code", "type", "param", "model", "provider"),
)
async def test_stream_parity_rejects_public_error_changes(
mode: Literal["sync", "async"], candidate_error: Exception
) -> None:
chunks: Final = (_Chunk(value="partial"),)
baseline: Final = await _consume(mode, chunks, _PublicStreamError())
candidate: Final = await _consume(mode, chunks, candidate_error)
with pytest.raises(AssertionError):
assert_stream_parity(baseline, candidate)
@pytest.mark.asyncio
@pytest.mark.parametrize("mode", ("sync", "async"))
@pytest.mark.parametrize("candidate", (("one",), ("one", "two", "three"), ("two", "one"), ("one", "changed")))
async def test_stream_parity_checks_event_sequence(mode: Literal["sync", "async"], candidate: tuple[str, ...]) -> None:
baseline: Final = await _consume(mode, (_Chunk(value="one"), _Chunk(value="two")))
changed: Final = await _consume(mode, tuple(_Chunk(value=value) for value in candidate))
with pytest.raises(AssertionError):
assert_stream_parity(baseline, changed)
@pytest.mark.asyncio
@pytest.mark.parametrize("mode", ("sync", "async"))
async def test_stream_parity_preserves_nested_types_and_ignores_private_fields(mode: Literal["sync", "async"]) -> None:
first: Final = _Chunk(value="same")
second: Final = _Chunk(value="same")
first.set_hidden_param("request_id", "first")
second.set_hidden_param("request_id", "second")
baseline: Final = await _consume(mode, (_NestedChunk(value=[first]),))
candidate: Final = await _consume(mode, (_NestedChunk(value=[second]),))
assert_stream_parity(baseline, candidate)
changed: Final = await _consume(mode, (_NestedChunk(value=[{"value": "same"}]),))
with pytest.raises(AssertionError, match=r"\$\.chunks\[0\]\.value\[0\]"):
assert_stream_parity(baseline, changed)
def test_stream_parity_rejects_wrapper_and_chunk_type_changes() -> None:
baseline: Final = consume_sync_stream(lambda: _SyncStream((_Chunk(value="same"),)))
different_wrapper: Final = consume_sync_stream(lambda: iter((_Chunk(value="same"),)))
different_chunk: Final = consume_sync_stream(lambda: _SyncStream(({"value": "same"},)))
with pytest.raises(AssertionError):
assert_stream_parity(baseline, different_wrapper)
with pytest.raises(AssertionError):
assert_stream_parity(baseline, different_chunk)
@pytest.mark.asyncio
@pytest.mark.parametrize("mode", ("sync", "async"))
@pytest.mark.parametrize("error", (None, _PublicStreamError()))
async def test_capture_keeps_serialization_failures_out_of_sdk_errors(
mode: Literal["sync", "async"], error: Exception | None
) -> None:
with pytest.raises(ValidationError):
await _capture(mode, (_Chunk(value="valid"), object()), error)
@pytest.mark.asyncio
@pytest.mark.parametrize("mode", ("sync", "async"))
async def test_empty_stream_completes(mode: Literal["sync", "async"]) -> None:
outcome: Final = await _consume(mode, ())
assert outcome.chunks == ()
assert outcome.terminal == StreamCompleted()
assert await _capture(mode, ()) == SDKStreamReport(chunks=(), terminal=SDKStreamCompleted())
@pytest.mark.asyncio
async def test_async_creation_error_matches_sync_capture() -> None:
async def create() -> _AsyncStream:
_creation_error()
sync: Final = consume_sync_stream(_creation_error)
asynchronous: Final = await consume_async_stream(create)
assert_stream_parity(sync, asynchronous)
assert isinstance(sync.terminal, StreamFailed)
assert sync.terminal.phase == "creation"
assert isinstance(capture_sync_stream(_creation_error), SDKError)
assert capture_sync_stream(_creation_error) == await capture_async_stream(create) == sync.terminal.error
@pytest.mark.asyncio
@pytest.mark.parametrize("mode", ("sync", "async"))
async def test_capture_preserves_partial_output_and_complete_error(mode: Literal["sync", "async"]) -> None:
error: Final = _PublicStreamError()
report: Final = await _capture(mode, (_Chunk(value="partial"),), error)
assert report == SDKStreamReport(
chunks=(SDKJsonChunk(value={"value": "partial"}),),
terminal=SDKStreamFailed(error=sdk_error_report(error)),
)

View file

@ -0,0 +1,43 @@
from __future__ import annotations
from typing import Final
import pytest
from tests.route_parity.fixtures.pytest_support import parametrize_recorded_fixtures
from tests.route_parity.fixtures.store import fixture_id
from tests.test_litellm.ocr.fixtures.config import DEFAULT_FIXTURE_DIRECTORY, FIXTURE_DIR_ENV
from tests.test_litellm.ocr.fixtures.models import OcrParityCase
def ocr_fixture_id(fixture: OcrParityCase) -> str:
case_input: Final = fixture.litellm_input
provider: Final = case_input.custom_llm_provider
prefix: Final = f"{provider}/{case_input.model}" if provider else case_input.model
return fixture_id(case_input, prefix)
def ocr_fixture_marks(fixture: OcrParityCase) -> tuple[pytest.MarkDecorator, ...]:
if fixture.litellm_input.contract not in {"reducto_v3", "reducto_legacy"}:
return ()
return (
pytest.mark.xfail(
reason="Reducto does not have a Rust OCR contract",
strict=False,
),
)
def pytest_generate_tests(metafunc: pytest.Metafunc) -> None:
parametrize_recorded_fixtures(
metafunc,
fixture_name="ocr_fixture",
case_type=OcrParityCase,
env_var=FIXTURE_DIR_ENV,
default_directory=DEFAULT_FIXTURE_DIRECTORY,
regeneration_command=(
f"uv run python -m tests.test_litellm.ocr.fixtures.record --fixture-dir {DEFAULT_FIXTURE_DIRECTORY}"
),
id_builder=ocr_fixture_id,
marks_builder=ocr_fixture_marks,
)

View file

@ -0,0 +1,49 @@
# OCR parity fixtures
The recording command runs four stages:
1. Generate deterministic SDK inputs for every configured OCR target
2. Build target-scoped, deduplicated recording jobs
3. Record upstream responses through one globally bounded worker pool
4. Persist each fixture and report whether it was recorded, cached, or failed
Run it with:
```shell
uv run python -m tests.test_litellm.ocr.fixtures.record --examples 4 --concurrency 4
```
`--concurrency` caps provider calls across all targets. Independent jobs finish after a failure, then the command exits
nonzero if any job failed
New recordings are VCR YAML cassettes. The committed corpus contains 31 migrated cassettes: 18 Mistral, 9 Reducto v3,
and 4 Reducto legacy. Their original response bytes, statuses, headers, and recording timestamps are preserved
To migrate an existing JSON fixture directory locally:
```shell
uv run python -m tests.test_litellm.ocr.fixtures.migrate --fixture-dir tests/test_litellm/ocr/fixtures/data
```
The migration replays each old response through the Python SDK to reconstruct missing requests, writes and validates
the YAML cassette, then removes its JSON predecessor. It calls only local recording/replay servers and needs no provider
credentials. Reconstructed requests are labeled `python_replay`; they are not historical wire captures. Filenames use
the current normalized SDK input hash, including the fixture contract
OCR strategies generate public `litellm.ocr()` and `litellm.aocr()` inputs. Every case contains the normalized model,
document, optional provider override, and LiteLLM keyword arguments. The fixture-only `contract` literal selects the
input schema and is removed before calling the SDK. Strategies never build provider wire payloads
Each contract has a required corpus containing a baseline and cases for its supported top-level OCR parameters. The
contracts are Mistral, Azure-hosted Mistral, Vertex-hosted Mistral, Azure Document Intelligence, Vertex DeepSeek,
Reducto v3, and Reducto legacy. Credentials and endpoints only control target discovery, so a machine records the
contracts it has configured and skips the rest
Reducto fixtures record upload and parse responses. Their parity cases remain non-strict expected failures until the
Rust OCR bridge supports Reducto. Azure and Vertex generation paths are unit-tested without credentials in CI, so the
committed corpus does not need live recordings for every target
Every recording target owns a small fixed provider-rejected corpus, independent of replay implementation support.
Those inputs are recorded separately from generated valid inputs. Local validation failures use no recorded response;
the parity suite checks those unsupported providers and models, malformed documents, invalid request formats, invalid
Azure Document Intelligence parameters, and invalid headers in sync and async SDK calls

View file

@ -0,0 +1 @@

View file

@ -0,0 +1,207 @@
from __future__ import annotations
from collections.abc import Mapping
from typing import Final, Literal, cast
from hypothesis import strategies as st
from hypothesis.strategies import SearchStrategy
from pydantic import StrictInt, StrictStr, field_validator
from tests.route_parity.fixtures.recording import UpstreamEndpoint
from tests.test_litellm.ocr.fixtures.base import OcrDocument, OcrSdkInputBase
from tests.test_litellm.ocr.fixtures.common import (
OcrFixtureClient,
OcrRecordingTarget,
image_document,
invoke_with_api_key,
pdf_document,
)
from tests.test_litellm.ocr.fixtures.mistral import (
MistralCompatibleOcrSdkInput,
mistral_input_values_strategy,
)
AzureMistralModel = Literal["azure_ai/mistral-document-ai-2512",]
AzureMistralFixtureModel = AzureMistralModel | Literal["azure_ai/invalid-ocr-model-for-parity"]
AzureDocumentIntelligenceModel = Literal[
"azure_ai/doc-intelligence/prebuilt-read",
"azure_ai/doc-intelligence/prebuilt-layout",
"azure_ai/doc-intelligence/prebuilt-document",
]
AzureDocumentIntelligenceFixtureModel = (
AzureDocumentIntelligenceModel | Literal["azure_ai/doc-intelligence/invalid-ocr-model-for-parity"]
)
AZURE_MISTRAL_MODELS: Final[tuple[AzureMistralModel, ...]] = ("azure_ai/mistral-document-ai-2512",)
AZURE_DOCUMENT_INTELLIGENCE_MODELS: Final[tuple[AzureDocumentIntelligenceModel, ...]] = (
"azure_ai/doc-intelligence/prebuilt-read",
"azure_ai/doc-intelligence/prebuilt-layout",
"azure_ai/doc-intelligence/prebuilt-document",
)
# API v4 replaces prebuilt-document with prebuilt-layout plus keyValuePairs. Keep
# the broader fixture model above so existing recordings remain loadable.
AZURE_DOCUMENT_INTELLIGENCE_RECORDING_MODELS: Final[tuple[AzureDocumentIntelligenceModel, ...]] = (
"azure_ai/doc-intelligence/prebuilt-read",
"azure_ai/doc-intelligence/prebuilt-layout",
)
class AzureMistralOcrSdkInput(MistralCompatibleOcrSdkInput):
contract: Literal["azure_mistral"] = "azure_mistral"
model: AzureMistralFixtureModel
custom_llm_provider: Literal["azure_ai"] | None = None
@field_validator("model")
@classmethod
def validate_model_namespace(cls, model: str) -> str:
if not model.startswith("azure_ai/"):
raise ValueError("Azure Mistral models must use the azure_ai/ LiteLLM namespace")
return model
class AzureDocumentIntelligenceOcrSdkInput(OcrSdkInputBase):
contract: Literal["azure_document_intelligence"] = "azure_document_intelligence"
model: AzureDocumentIntelligenceFixtureModel
document: OcrDocument
custom_llm_provider: Literal["azure_ai"] | None = None
pages: str | list[StrictInt] | list[StrictStr] | None = None
features: str | list[str] | None = None
req_format: Literal["litellm"] = "litellm"
AZURE_MISTRAL_PROVIDER_REJECTED_INPUTS: Final[tuple[AzureMistralOcrSdkInput, ...]] = (
AzureMistralOcrSdkInput(
model="azure_ai/invalid-ocr-model-for-parity",
document=pdf_document(),
),
)
AZURE_DOCUMENT_INTELLIGENCE_PROVIDER_REJECTED_INPUTS: Final[tuple[AzureDocumentIntelligenceOcrSdkInput, ...]] = (
AzureDocumentIntelligenceOcrSdkInput(
model="azure_ai/doc-intelligence/invalid-ocr-model-for-parity",
document=pdf_document(),
),
)
def _azure_mistral_input(values: dict[str, object], model: AzureMistralModel) -> AzureMistralOcrSdkInput:
return AzureMistralOcrSdkInput.model_validate({**values, "model": model})
def azure_mistral_input_strategy(inline_image_data_uri: str) -> SearchStrategy[AzureMistralOcrSdkInput]:
# Foundry's active gateway schema rejects 2512-only controls and
# document_annotation_prompt, even though native Mistral accepts them.
return st.builds(
_azure_mistral_input,
values=mistral_input_values_strategy("2505", inline_image_data_uri, include_document_annotation_prompt=False),
model=st.sampled_from(AZURE_MISTRAL_MODELS),
)
_AZURE_DOCUMENT_INTELLIGENCE_CANONICAL_MODEL: Final[AzureDocumentIntelligenceModel] = (
"azure_ai/doc-intelligence/prebuilt-layout"
)
def _document_intelligence_input(
model: AzureDocumentIntelligenceModel,
document: OcrDocument,
optional_params: Mapping[str, object] | None = None,
) -> AzureDocumentIntelligenceOcrSdkInput:
return AzureDocumentIntelligenceOcrSdkInput.model_validate(
{"model": model, "document": document, **(optional_params or {})}
)
def azure_document_intelligence_input_strategy() -> SearchStrategy[AzureDocumentIntelligenceOcrSdkInput]:
document: Final = pdf_document()
pages: Final = st.one_of(
st.sampled_from(((0,), (2, 0, 0, 1))).map(list),
st.just(["1", "2-4"]),
st.just("1-4, 5"),
).map(lambda value: {"pages": value})
features: Final = st.one_of(
st.sampled_from(
(
("languages",),
("ocrHighResolution",),
("barcodes",),
("formulas",),
("styleFont",),
("keyValuePairs",),
)
).map(list),
st.just("languages, styleFont"),
).map(lambda value: {"features": value})
combined_query: Final = st.just({"pages": (0, 1), "features": ("languages", "styleFont")})
return st.one_of(
st.sampled_from(AZURE_DOCUMENT_INTELLIGENCE_RECORDING_MODELS).map(
lambda model: _document_intelligence_input(model, document)
),
st.just(
_document_intelligence_input(
_AZURE_DOCUMENT_INTELLIGENCE_CANONICAL_MODEL,
image_document("invoice 123", 24),
)
),
pages.map(
lambda optional_params: _document_intelligence_input(
_AZURE_DOCUMENT_INTELLIGENCE_CANONICAL_MODEL, document, optional_params
)
),
features.map(
lambda optional_params: _document_intelligence_input(
_AZURE_DOCUMENT_INTELLIGENCE_CANONICAL_MODEL, document, optional_params
)
),
combined_query.map(
lambda optional_params: _document_intelligence_input(
_AZURE_DOCUMENT_INTELLIGENCE_CANONICAL_MODEL, document, optional_params
)
),
st.just(
_document_intelligence_input(
_AZURE_DOCUMENT_INTELLIGENCE_CANONICAL_MODEL,
document,
{"req_format": "litellm"},
)
),
)
def azure_mistral_recording_targets(
environ: Mapping[str, str], client: OcrFixtureClient, inline_image_data_uri: str
) -> tuple[OcrRecordingTarget, ...]:
api_key: Final = environ.get("AZURE_AI_API_KEY")
base_url: Final = environ.get("AZURE_AI_API_BASE")
if not api_key or not base_url:
return ()
return (
OcrRecordingTarget(
name="azure-mistral",
upstream=UpstreamEndpoint(base_url=base_url.rstrip("/")),
strategy=cast(
SearchStrategy[OcrSdkInputBase],
azure_mistral_input_strategy(inline_image_data_uri),
),
invocation=invoke_with_api_key(client, api_key),
required_inputs=AZURE_MISTRAL_PROVIDER_REJECTED_INPUTS,
),
)
def azure_document_intelligence_recording_targets(
environ: Mapping[str, str], client: OcrFixtureClient
) -> tuple[OcrRecordingTarget, ...]:
api_key: Final = environ.get("AZURE_DOCUMENT_INTELLIGENCE_API_KEY")
base_url: Final = environ.get("AZURE_DOCUMENT_INTELLIGENCE_ENDPOINT")
if not api_key or not base_url:
return ()
return (
OcrRecordingTarget(
name="azure-document-intelligence",
upstream=UpstreamEndpoint(base_url=base_url.rstrip("/")),
strategy=cast(SearchStrategy[OcrSdkInputBase], azure_document_intelligence_input_strategy()),
invocation=invoke_with_api_key(client, api_key),
required_inputs=AZURE_DOCUMENT_INTELLIGENCE_PROVIDER_REJECTED_INPUTS,
),
)

View file

@ -0,0 +1,48 @@
from __future__ import annotations
from typing import Annotated, Literal
from pydantic import Field
from tests.route_parity.fixture_models import (
FixtureModel,
JsonSchemaDefinition,
JsonSchemaResponseFormat,
SdkInputBase,
)
__all__ = (
"DocumentUrlDocument",
"ImageUrlDocument",
"ImageUrlValue",
"JsonSchemaDefinition",
"JsonSchemaResponseFormat",
"OcrDocument",
"OcrSdkInputBase",
)
class OcrSdkInputBase(SdkInputBase):
fixture_only_fields = ("contract",)
class ImageUrlValue(FixtureModel):
url: str
detail: Literal["low", "auto", "high"] | None = None
class ImageUrlDocument(FixtureModel):
type: Literal["image_url"]
image_url: str | ImageUrlValue
class DocumentUrlDocument(FixtureModel):
type: Literal["document_url"]
document_url: str
document_name: str | None = None
OcrDocument = Annotated[
ImageUrlDocument | DocumentUrlDocument,
Field(discriminator="type"),
]

View file

@ -0,0 +1,101 @@
from __future__ import annotations
from dataclasses import dataclass, field
from functools import cache
from typing import Final, Literal, Protocol
from hypothesis import strategies as st
from hypothesis.strategies import SearchStrategy
from tests.route_parity.fixtures.pipeline import RecordingTarget
from tests.route_parity.fixtures.media import dummy_image_url, structured_pdf_data_uri
from tests.test_litellm.ocr.fixtures.base import (
DocumentUrlDocument,
ImageUrlDocument,
JsonSchemaDefinition,
JsonSchemaResponseFormat,
OcrSdkInputBase,
)
OcrRecordingTarget = RecordingTarget[OcrSdkInputBase]
class OcrFixtureClient(Protocol):
def execute(self, api_base: str, api_key: str, case_input: OcrSdkInputBase) -> None: ...
class OcrSdkCall(Protocol):
def __call__(self, **kwargs: object) -> object: ...
@dataclass(frozen=True, slots=True)
class ApiKeyOcrInvocation:
client: OcrFixtureClient
api_key: str = field(repr=False)
def execute(self, provider_url: str, case_input: OcrSdkInputBase) -> None:
self.client.execute(provider_url, self.api_key, case_input)
def image_document(text: str, font_size: int) -> ImageUrlDocument:
return ImageUrlDocument(type="image_url", image_url=dummy_image_url(text, font_size))
def image_data_document(data_uri: str) -> ImageUrlDocument:
return ImageUrlDocument(type="image_url", image_url=data_uri)
@cache
def remote_pdf_document() -> DocumentUrlDocument:
return DocumentUrlDocument(
type="document_url",
document_url="https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf",
)
@cache
def pdf_document() -> DocumentUrlDocument:
return DocumentUrlDocument(type="document_url", document_url=structured_pdf_data_uri())
def document_transport_strategy(inline_image_data_uri: str) -> SearchStrategy[ImageUrlDocument | DocumentUrlDocument]:
transports: Final[tuple[Literal["remote_image", "inline_image", "remote_pdf", "inline_pdf"], ...]] = (
"remote_image",
"inline_image",
"remote_pdf",
"inline_pdf",
)
def as_document(
transport: Literal["remote_image", "inline_image", "remote_pdf", "inline_pdf"],
) -> ImageUrlDocument | DocumentUrlDocument:
if transport == "remote_image":
return image_document("invoice 123", 24)
if transport == "inline_image":
return image_data_document(inline_image_data_uri)
if transport == "remote_pdf":
return remote_pdf_document()
return pdf_document()
return st.sampled_from(transports).map(as_document)
def annotation_format(name: str) -> JsonSchemaResponseFormat:
return JsonSchemaResponseFormat(
type="json_schema",
json_schema=JsonSchemaDefinition(
name=name,
description="Extract the visible document fields",
schema={
"type": "object",
"properties": {"title": {"type": "string"}},
"required": ["title"],
"additionalProperties": False,
},
strict=True,
),
)
def invoke_with_api_key(client: OcrFixtureClient, api_key: str) -> ApiKeyOcrInvocation:
return ApiKeyOcrInvocation(client=client, api_key=api_key)

View file

@ -0,0 +1,13 @@
from __future__ import annotations
import os
from pathlib import Path
from typing import Final
FIXTURE_DIR_ENV: Final = "LITELLM_OCR_FIXTURE_DIR"
DEFAULT_FIXTURE_DIRECTORY: Final = Path(__file__).with_name("data")
def configured_fixture_directory() -> Path:
configured: Final = os.environ.get(FIXTURE_DIR_ENV)
return Path(configured).expanduser() if configured is not None else DEFAULT_FIXTURE_DIRECTORY

View file

@ -0,0 +1,68 @@
interactions:
- request:
body: '{"model":"mistral-ocr-latest","document":{"type":"image_url","image_url":"https://dummyjson.com/image/800x300/ffffff/000000?text=invoice%20123&fontSize=24"},"image_limit":1}'
headers:
Accept:
- '*/*'
Content-Type:
- application/json
User-Agent:
- litellm/1.101.0
method: POST
uri: http://parity-provider.invalid/v1/ocr
response:
body:
string: '{"pages":[{"index":0,"markdown":"invoice 123","images":[],"tables":[],"hyperlinks":[],"header":null,"footer":null,"dimensions":{"dpi":200,"height":300,"width":800},"confidence_scores":null,"blocks":[{"top_left_x":335,"top_left_y":138,"bottom_right_x":464,"bottom_right_y":162,"content":"invoice
123","confidence_scores":null,"type":"text"}]}],"model":"mistral-ocr-latest","document_annotation":null,"usage_info":{"pages_processed":1,"doc_size_bytes":4124}}'
headers:
CF-RAY:
- a346d3637dc2c090-SJC
Content-Type:
- application/json
Date:
- Tue, 01 Sep 2026 19:54:15 GMT
Server:
- cloudflare
Strict-Transport-Security:
- max-age=15552000; includeSubDomains; preload
X-Content-Type-Options:
- nosniff
access-control-allow-origin:
- '*'
alt-svc:
- h3=":443"; ma=86400
cf-cache-status:
- DYNAMIC
mistral-correlation-id:
- 01a05e89-5a87-7148-8ae4-4e14967bebf3
x-envoy-upstream-service-time:
- '226'
x-kong-proxy-latency:
- '19'
x-kong-request-id:
- 01a05e89-5a87-7148-8ae4-4e14967bebf3
x-kong-upstream-latency:
- '227'
x-ratelimit-limit-ocr-pages-minute:
- '60'
x-ratelimit-ocr-pages-query-cost:
- '1'
x-ratelimit-remaining-ocr-pages-minute:
- '56'
status:
code: 200
message: ''
recorded_at: '2026-09-01T19:54:15.394028+00:00'
ttl_seconds: 0
version: 1
x-litellm:
case:
litellm_input:
contract: mistral
document:
image_url: https://dummyjson.com/image/800x300/ffffff/000000?text=invoice%20123&fontSize=24
type: image_url
image_limit: 1
model: mistral/mistral-ocr-latest
request_source: python_replay
schema_version: 1

View file

@ -0,0 +1,69 @@
interactions:
- request:
body: '{"model":"mistral-ocr-latest","document":{"type":"image_url","image_url":"https://dummyjson.com/image/800x300/ffffff/000000?text=invoice%20123&fontSize=24"},"pages":[0]}'
headers:
Accept:
- '*/*'
Content-Type:
- application/json
User-Agent:
- litellm/1.101.0
method: POST
uri: http://parity-provider.invalid/v1/ocr
response:
body:
string: '{"pages":[{"index":0,"markdown":"invoice 123","images":[],"tables":[],"hyperlinks":[],"header":null,"footer":null,"dimensions":{"dpi":200,"height":300,"width":800},"confidence_scores":null,"blocks":[{"top_left_x":335,"top_left_y":138,"bottom_right_x":464,"bottom_right_y":162,"content":"invoice
123","confidence_scores":null,"type":"text"}]}],"model":"mistral-ocr-latest","document_annotation":null,"usage_info":{"pages_processed":1,"doc_size_bytes":4124}}'
headers:
CF-RAY:
- a346d35d1fbbebe5-SJC
Content-Type:
- application/json
Date:
- Tue, 01 Sep 2026 19:54:14 GMT
Server:
- cloudflare
Strict-Transport-Security:
- max-age=15552000; includeSubDomains; preload
X-Content-Type-Options:
- nosniff
access-control-allow-origin:
- '*'
alt-svc:
- h3=":443"; ma=86400
cf-cache-status:
- DYNAMIC
mistral-correlation-id:
- 01a05e89-5685-747f-b76d-dab242ea7512
x-envoy-upstream-service-time:
- '179'
x-kong-proxy-latency:
- '12'
x-kong-request-id:
- 01a05e89-5685-747f-b76d-dab242ea7512
x-kong-upstream-latency:
- '180'
x-ratelimit-limit-ocr-pages-minute:
- '60'
x-ratelimit-ocr-pages-query-cost:
- '1'
x-ratelimit-remaining-ocr-pages-minute:
- '58'
status:
code: 200
message: ''
recorded_at: '2026-09-01T19:54:14.385374+00:00'
ttl_seconds: 0
version: 1
x-litellm:
case:
litellm_input:
contract: mistral
document:
image_url: https://dummyjson.com/image/800x300/ffffff/000000?text=invoice%20123&fontSize=24
type: image_url
model: mistral/mistral-ocr-latest
pages:
- 0
request_source: python_replay
schema_version: 1

View file

@ -0,0 +1,85 @@
interactions:
- request:
body: '{"model":"mistral-ocr-latest","document":{"type":"image_url","image_url":"https://dummyjson.com/image/800x300/ffffff/000000?text=invoice%20123&fontSize=24"},"document_annotation_format":{"type":"json_schema","json_schema":{"name":"document_title","description":"Extract
the visible document fields","schema":{"additionalProperties":false,"properties":{"title":{"type":"string"}},"required":["title"],"type":"object"},"strict":true}},"document_annotation_prompt":"Extract
the visible title"}'
headers:
Accept:
- '*/*'
Content-Type:
- application/json
User-Agent:
- litellm/1.101.0
method: POST
uri: http://parity-provider.invalid/v1/ocr
response:
body:
string: '{"pages":[{"index":0,"markdown":"invoice 123","images":[],"tables":[],"hyperlinks":[],"header":null,"footer":null,"dimensions":{"dpi":200,"height":300,"width":800},"confidence_scores":null,"blocks":[{"top_left_x":335,"top_left_y":138,"bottom_right_x":464,"bottom_right_y":162,"content":"invoice
123","confidence_scores":null,"type":"text"}]}],"model":"mistral-ocr-latest","document_annotation":"{\"title\":
\"invoice 123\"}","usage_info":{"pages_processed":1,"doc_size_bytes":4124}}'
headers:
CF-RAY:
- a346d376581f74f9-SJC
Content-Type:
- application/json
Date:
- Tue, 01 Sep 2026 19:54:18 GMT
Server:
- cloudflare
Strict-Transport-Security:
- max-age=15552000; includeSubDomains; preload
X-Content-Type-Options:
- nosniff
access-control-allow-origin:
- '*'
alt-svc:
- h3=":443"; ma=86400
cf-cache-status:
- DYNAMIC
mistral-correlation-id:
- 01a05e89-664a-740d-a112-0a1183c302b3
x-envoy-upstream-service-time:
- '402'
x-kong-proxy-latency:
- '20'
x-kong-request-id:
- 01a05e89-664a-740d-a112-0a1183c302b3
x-kong-upstream-latency:
- '403'
x-ratelimit-limit-ocr-pages-minute:
- '60'
x-ratelimit-ocr-pages-query-cost:
- '1'
x-ratelimit-remaining-ocr-pages-minute:
- '52'
status:
code: 200
message: ''
recorded_at: '2026-09-01T19:54:18.915814+00:00'
ttl_seconds: 0
version: 1
x-litellm:
case:
litellm_input:
contract: mistral
document:
image_url: https://dummyjson.com/image/800x300/ffffff/000000?text=invoice%20123&fontSize=24
type: image_url
document_annotation_format:
json_schema:
description: Extract the visible document fields
name: document_title
schema:
additionalProperties: false
properties:
title:
type: string
required:
- title
type: object
strict: true
type: json_schema
document_annotation_prompt: Extract the visible title
model: mistral/mistral-ocr-latest
request_source: python_replay
schema_version: 1

View file

@ -0,0 +1,68 @@
interactions:
- request:
body: '{"model":"mistral-ocr-latest","document":{"type":"image_url","image_url":"https://dummyjson.com/image/800x300/ffffff/000000?text=invoice%20123&fontSize=24"},"extract_header":true}'
headers:
Accept:
- '*/*'
Content-Type:
- application/json
User-Agent:
- litellm/1.101.0
method: POST
uri: http://parity-provider.invalid/v1/ocr
response:
body:
string: '{"pages":[{"index":0,"markdown":"invoice 123","images":[],"tables":[],"hyperlinks":[],"header":null,"footer":null,"dimensions":{"dpi":200,"height":300,"width":800},"confidence_scores":null,"blocks":[{"top_left_x":335,"top_left_y":138,"bottom_right_x":464,"bottom_right_y":162,"content":"invoice
123","confidence_scores":null,"type":"text"}]}],"model":"mistral-ocr-latest","document_annotation":null,"usage_info":{"pages_processed":1,"doc_size_bytes":4124}}'
headers:
CF-RAY:
- a346d37c9944d8a7-SJC
Content-Type:
- application/json
Date:
- Tue, 01 Sep 2026 19:54:19 GMT
Server:
- cloudflare
Strict-Transport-Security:
- max-age=15552000; includeSubDomains; preload
X-Content-Type-Options:
- nosniff
access-control-allow-origin:
- '*'
alt-svc:
- h3=":443"; ma=86400
cf-cache-status:
- DYNAMIC
mistral-correlation-id:
- 01a05e89-6a3a-76c7-85b8-d40a7156155e
x-envoy-upstream-service-time:
- '230'
x-kong-proxy-latency:
- '16'
x-kong-request-id:
- 01a05e89-6a3a-76c7-85b8-d40a7156155e
x-kong-upstream-latency:
- '230'
x-ratelimit-limit-ocr-pages-minute:
- '60'
x-ratelimit-ocr-pages-query-cost:
- '1'
x-ratelimit-remaining-ocr-pages-minute:
- '51'
status:
code: 200
message: ''
recorded_at: '2026-09-01T19:54:19.420285+00:00'
ttl_seconds: 0
version: 1
x-litellm:
case:
litellm_input:
contract: mistral
document:
image_url: https://dummyjson.com/image/800x300/ffffff/000000?text=invoice%20123&fontSize=24
type: image_url
extract_header: true
model: mistral/mistral-ocr-latest
request_source: python_replay
schema_version: 1

View file

@ -0,0 +1,67 @@
interactions:
- request:
body: '{"model":"mistral-ocr-latest","document":{"type":"image_url","image_url":"https://dummyjson.com/image/800x300/ffffff/000000?text=invoice%20123&fontSize=24"}}'
headers:
Accept:
- '*/*'
Content-Type:
- application/json
User-Agent:
- litellm/1.101.0
method: POST
uri: http://parity-provider.invalid/v1/ocr
response:
body:
string: '{"pages":[{"index":0,"markdown":"invoice 123","images":[],"tables":[],"hyperlinks":[],"header":null,"footer":null,"dimensions":{"dpi":200,"height":300,"width":800},"confidence_scores":null,"blocks":[{"top_left_x":335,"top_left_y":138,"bottom_right_x":464,"bottom_right_y":162,"content":"invoice
123","confidence_scores":null,"type":"text"}]}],"model":"mistral-ocr-latest","document_annotation":null,"usage_info":{"pages_processed":1,"doc_size_bytes":4124}}'
headers:
CF-RAY:
- a346d356fb3698ce-SJC
Content-Type:
- application/json
Date:
- Tue, 01 Sep 2026 19:54:13 GMT
Server:
- cloudflare
Strict-Transport-Security:
- max-age=15552000; includeSubDomains; preload
X-Content-Type-Options:
- nosniff
access-control-allow-origin:
- '*'
alt-svc:
- h3=":443"; ma=86400
cf-cache-status:
- DYNAMIC
mistral-correlation-id:
- 01a05e89-52b4-7e7b-983e-5bc4e5469b39
x-envoy-upstream-service-time:
- '554'
x-kong-proxy-latency:
- '13'
x-kong-request-id:
- 01a05e89-52b4-7e7b-983e-5bc4e5469b39
x-kong-upstream-latency:
- '557'
x-ratelimit-limit-ocr-pages-minute:
- '60'
x-ratelimit-ocr-pages-query-cost:
- '1'
x-ratelimit-remaining-ocr-pages-minute:
- '59'
status:
code: 200
message: ''
recorded_at: '2026-09-01T19:54:13.880852+00:00'
ttl_seconds: 0
version: 1
x-litellm:
case:
litellm_input:
contract: mistral
document:
image_url: https://dummyjson.com/image/800x300/ffffff/000000?text=invoice%20123&fontSize=24
type: image_url
model: mistral/mistral-ocr-latest
request_source: python_replay
schema_version: 1

View file

@ -0,0 +1,82 @@
interactions:
- request:
body: '{"model":"mistral-ocr-latest","document":{"type":"image_url","image_url":"https://dummyjson.com/image/800x300/ffffff/000000?text=invoice%20123&fontSize=24"},"bbox_annotation_format":{"type":"json_schema","json_schema":{"name":"bounding_boxes","description":"Extract
the visible document fields","schema":{"additionalProperties":false,"properties":{"title":{"type":"string"}},"required":["title"],"type":"object"},"strict":true}}}'
headers:
Accept:
- '*/*'
Content-Type:
- application/json
User-Agent:
- litellm/1.101.0
method: POST
uri: http://parity-provider.invalid/v1/ocr
response:
body:
string: '{"pages":[{"index":0,"markdown":"invoice 123","images":[],"tables":[],"hyperlinks":[],"header":null,"footer":null,"dimensions":{"dpi":200,"height":300,"width":800},"confidence_scores":null,"blocks":[{"top_left_x":335,"top_left_y":138,"bottom_right_x":464,"bottom_right_y":162,"content":"invoice
123","confidence_scores":null,"type":"text"}]}],"model":"mistral-ocr-latest","document_annotation":null,"usage_info":{"pages_processed":1,"doc_size_bytes":4124}}'
headers:
CF-RAY:
- a346d369be838fc5-SJC
Content-Type:
- application/json
Date:
- Tue, 01 Sep 2026 19:54:16 GMT
Server:
- cloudflare
Strict-Transport-Security:
- max-age=15552000; includeSubDomains; preload
X-Content-Type-Options:
- nosniff
access-control-allow-origin:
- '*'
alt-svc:
- h3=":443"; ma=86400
cf-cache-status:
- DYNAMIC
mistral-correlation-id:
- 01a05e89-5e6e-70ac-890d-c0110a5e81af
x-envoy-upstream-service-time:
- '210'
x-kong-proxy-latency:
- '17'
x-kong-request-id:
- 01a05e89-5e6e-70ac-890d-c0110a5e81af
x-kong-upstream-latency:
- '212'
x-ratelimit-limit-ocr-pages-minute:
- '60'
x-ratelimit-ocr-pages-query-cost:
- '1'
x-ratelimit-remaining-ocr-pages-minute:
- '54'
status:
code: 200
message: ''
recorded_at: '2026-09-01T19:54:16.402593+00:00'
ttl_seconds: 0
version: 1
x-litellm:
case:
litellm_input:
bbox_annotation_format:
json_schema:
description: Extract the visible document fields
name: bounding_boxes
schema:
additionalProperties: false
properties:
title:
type: string
required:
- title
type: object
strict: true
type: json_schema
contract: mistral
document:
image_url: https://dummyjson.com/image/800x300/ffffff/000000?text=invoice%20123&fontSize=24
type: image_url
model: mistral/mistral-ocr-latest
request_source: python_replay
schema_version: 1

View file

@ -0,0 +1,92 @@
interactions:
- request:
body: '{"model":"mistral-ocr-latest","document":{"type":"document_url","document_url":"data:application/pdf;base64,JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvUmVzb3VyY2VzIDQgMCBSIC9NZWRpYUJveCBbMCAwIDYxMiA3OTJdIC9Db250ZW50cyA1IDAgUiA+PgplbmRvYmoKNCAwIG9iago8PCAvRm9udCA8PCAvRjEgPDwgL1R5cGUgL0ZvbnQgL1N1YnR5cGUgL1R5cGUxIC9CYXNlRm9udCAvSGVsdmV0aWNhID4+ID4+ID4+CmVuZG9iago1IDAgb2JqCjw8IC9MZW5ndGggNDQgPj4Kc3RyZWFtCkJUCi9GMSAxMiBUZgoxMDAgNzAwIFRkCihUZXN0IFBERiBGaWxlKSBUagpFVAplbmRzdHJlYW0KZW5kb2JqCnhyZWYKMCA2CjAwMDAwMDAwMDAgNjU1MzUgZiAKMDAwMDAwMDAwOSAwMDAwMCBuIAowMDAwMDAwMDU4IDAwMDAwIG4gCjAwMDAwMDAxMTUgMDAwMDAgbiAKMDAwMDAwMDIxNCAwMDAwMCBuIAowMDAwMDAwMjkzIDAwMDAwIG4gCnRyYWlsZXIKPDwgL1NpemUgNiAvUm9vdCAxIDAgUiA+PgpzdGFydHhyZWYKMzg1CiUlRU9GCg=="},"pages":[0],"include_image_base64":true,"image_limit":1,"image_min_size":300,"bbox_annotation_format":{"type":"json_schema","json_schema":{"name":"bounding_boxes","description":"Extract
the visible document fields","schema":{"additionalProperties":false,"properties":{"title":{"type":"string"}},"required":["title"],"type":"object"},"strict":true}},"extract_header":true,"extract_footer":false,"table_format":"markdown","confidence_scores_granularity":"page","include_blocks":false,"id":"case-1"}'
headers:
Accept:
- '*/*'
Content-Type:
- application/json
User-Agent:
- litellm/1.101.0
method: POST
uri: http://parity-provider.invalid/v1/ocr
response:
body:
string: '{"pages":[{"index":0,"markdown":"Test PDF File","images":[],"tables":[],"hyperlinks":[],"header":null,"footer":null,"dimensions":{"dpi":93,"height":1023,"width":791},"confidence_scores":{"word_confidence_scores":[],"average_page_confidence_score":0.9376229744322936,"minimum_page_confidence_score":0.22590550796036835},"blocks":null}],"model":"mistral-ocr-latest","document_annotation":null,"usage_info":{"pages_processed":1,"doc_size_bytes":589}}'
headers:
CF-RAY:
- a346d39c1fe5cf12-SJC
Content-Type:
- application/json
Date:
- Tue, 01 Sep 2026 19:54:24 GMT
Server:
- cloudflare
Strict-Transport-Security:
- max-age=15552000; includeSubDomains; preload
X-Content-Type-Options:
- nosniff
access-control-allow-origin:
- '*'
alt-svc:
- h3=":443"; ma=86400
cf-cache-status:
- DYNAMIC
mistral-correlation-id:
- 01a05e89-7deb-72b4-976d-4c244b056782
x-envoy-upstream-service-time:
- '373'
x-kong-proxy-latency:
- '17'
x-kong-request-id:
- 01a05e89-7deb-72b4-976d-4c244b056782
x-kong-upstream-latency:
- '373'
x-ratelimit-limit-ocr-pages-minute:
- '60'
x-ratelimit-ocr-pages-query-cost:
- '1'
x-ratelimit-remaining-ocr-pages-minute:
- '45'
status:
code: 200
message: ''
recorded_at: '2026-09-01T19:54:24.951956+00:00'
ttl_seconds: 0
version: 1
x-litellm:
case:
litellm_input:
bbox_annotation_format:
json_schema:
description: Extract the visible document fields
name: bounding_boxes
schema:
additionalProperties: false
properties:
title:
type: string
required:
- title
type: object
strict: true
type: json_schema
confidence_scores_granularity: page
contract: mistral
document:
document_url: data:application/pdf;base64,JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvUmVzb3VyY2VzIDQgMCBSIC9NZWRpYUJveCBbMCAwIDYxMiA3OTJdIC9Db250ZW50cyA1IDAgUiA+PgplbmRvYmoKNCAwIG9iago8PCAvRm9udCA8PCAvRjEgPDwgL1R5cGUgL0ZvbnQgL1N1YnR5cGUgL1R5cGUxIC9CYXNlRm9udCAvSGVsdmV0aWNhID4+ID4+ID4+CmVuZG9iago1IDAgb2JqCjw8IC9MZW5ndGggNDQgPj4Kc3RyZWFtCkJUCi9GMSAxMiBUZgoxMDAgNzAwIFRkCihUZXN0IFBERiBGaWxlKSBUagpFVAplbmRzdHJlYW0KZW5kb2JqCnhyZWYKMCA2CjAwMDAwMDAwMDAgNjU1MzUgZiAKMDAwMDAwMDAwOSAwMDAwMCBuIAowMDAwMDAwMDU4IDAwMDAwIG4gCjAwMDAwMDAxMTUgMDAwMDAgbiAKMDAwMDAwMDIxNCAwMDAwMCBuIAowMDAwMDAwMjkzIDAwMDAwIG4gCnRyYWlsZXIKPDwgL1NpemUgNiAvUm9vdCAxIDAgUiA+PgpzdGFydHhyZWYKMzg1CiUlRU9GCg==
type: document_url
extract_footer: false
extract_header: true
id: case-1
image_limit: 1
image_min_size: 300
include_blocks: false
include_image_base64: true
model: mistral/mistral-ocr-latest
pages:
- 0
table_format: markdown
request_source: python_replay
schema_version: 1

View file

@ -0,0 +1,83 @@
interactions:
- request:
body: '{"model":"mistral-ocr-latest","document":{"type":"image_url","image_url":"https://dummyjson.com/image/800x300/ffffff/000000?text=invoice%20123&fontSize=24"},"document_annotation_format":{"type":"json_schema","json_schema":{"name":"document_title","description":"Extract
the visible document fields","schema":{"additionalProperties":false,"properties":{"title":{"type":"string"}},"required":["title"],"type":"object"},"strict":true}}}'
headers:
Accept:
- '*/*'
Content-Type:
- application/json
User-Agent:
- litellm/1.101.0
method: POST
uri: http://parity-provider.invalid/v1/ocr
response:
body:
string: '{"pages":[{"index":0,"markdown":"invoice 123","images":[],"tables":[],"hyperlinks":[],"header":null,"footer":null,"dimensions":{"dpi":200,"height":300,"width":800},"confidence_scores":null,"blocks":[{"top_left_x":335,"top_left_y":138,"bottom_right_x":464,"bottom_right_y":162,"content":"invoice
123","confidence_scores":null,"type":"text"}]}],"model":"mistral-ocr-latest","document_annotation":"{\"title\":
\"Invoice_123\"}","usage_info":{"pages_processed":1,"doc_size_bytes":4124}}'
headers:
CF-RAY:
- a346d36cd82f15ba-SJC
Content-Type:
- application/json
Date:
- Tue, 01 Sep 2026 19:54:17 GMT
Server:
- cloudflare
Strict-Transport-Security:
- max-age=15552000; includeSubDomains; preload
X-Content-Type-Options:
- nosniff
access-control-allow-origin:
- '*'
alt-svc:
- h3=":443"; ma=86400
cf-cache-status:
- DYNAMIC
mistral-correlation-id:
- 01a05e89-6060-71cb-9e35-66ab873b99f3
x-envoy-upstream-service-time:
- '888'
x-kong-proxy-latency:
- '12'
x-kong-request-id:
- 01a05e89-6060-71cb-9e35-66ab873b99f3
x-kong-upstream-latency:
- '889'
x-ratelimit-limit-ocr-pages-minute:
- '60'
x-ratelimit-ocr-pages-query-cost:
- '1'
x-ratelimit-remaining-ocr-pages-minute:
- '53'
status:
code: 200
message: ''
recorded_at: '2026-09-01T19:54:17.908846+00:00'
ttl_seconds: 0
version: 1
x-litellm:
case:
litellm_input:
contract: mistral
document:
image_url: https://dummyjson.com/image/800x300/ffffff/000000?text=invoice%20123&fontSize=24
type: image_url
document_annotation_format:
json_schema:
description: Extract the visible document fields
name: document_title
schema:
additionalProperties: false
properties:
title:
type: string
required:
- title
type: object
strict: true
type: json_schema
model: mistral/mistral-ocr-latest
request_source: python_replay
schema_version: 1

View file

@ -0,0 +1,68 @@
interactions:
- request:
body: '{"model":"mistral-ocr-latest","document":{"type":"image_url","image_url":"https://dummyjson.com/image/800x300/ffffff/000000?text=invoice%20123&fontSize=24"},"include_image_base64":true}'
headers:
Accept:
- '*/*'
Content-Type:
- application/json
User-Agent:
- litellm/1.101.0
method: POST
uri: http://parity-provider.invalid/v1/ocr
response:
body:
string: '{"pages":[{"index":0,"markdown":"invoice 123","images":[],"tables":[],"hyperlinks":[],"header":null,"footer":null,"dimensions":{"dpi":200,"height":300,"width":800},"confidence_scores":null,"blocks":[{"top_left_x":335,"top_left_y":138,"bottom_right_x":464,"bottom_right_y":162,"content":"invoice
123","confidence_scores":null,"type":"text"}]}],"model":"mistral-ocr-latest","document_annotation":null,"usage_info":{"pages_processed":1,"doc_size_bytes":4124}}'
headers:
CF-RAY:
- a346d360593c138a-SJC
Content-Type:
- application/json
Date:
- Tue, 01 Sep 2026 19:54:14 GMT
Server:
- cloudflare
Strict-Transport-Security:
- max-age=15552000; includeSubDomains; preload
X-Content-Type-Options:
- nosniff
access-control-allow-origin:
- '*'
alt-svc:
- h3=":443"; ma=86400
cf-cache-status:
- DYNAMIC
mistral-correlation-id:
- 01a05e89-588e-7e32-a54b-f816bc6c1dc4
x-envoy-upstream-service-time:
- '242'
x-kong-proxy-latency:
- '16'
x-kong-request-id:
- 01a05e89-588e-7e32-a54b-f816bc6c1dc4
x-kong-upstream-latency:
- '242'
x-ratelimit-limit-ocr-pages-minute:
- '60'
x-ratelimit-ocr-pages-query-cost:
- '1'
x-ratelimit-remaining-ocr-pages-minute:
- '57'
status:
code: 200
message: ''
recorded_at: '2026-09-01T19:54:14.889667+00:00'
ttl_seconds: 0
version: 1
x-litellm:
case:
litellm_input:
contract: mistral
document:
image_url: https://dummyjson.com/image/800x300/ffffff/000000?text=invoice%20123&fontSize=24
type: image_url
include_image_base64: true
model: mistral/mistral-ocr-latest
request_source: python_replay
schema_version: 1

View file

@ -0,0 +1,68 @@
interactions:
- request:
body: '{"model":"mistral-ocr-latest","document":{"type":"image_url","image_url":"https://dummyjson.com/image/800x300/ffffff/000000?text=invoice%20123&fontSize=24"},"image_min_size":300}'
headers:
Accept:
- '*/*'
Content-Type:
- application/json
User-Agent:
- litellm/1.101.0
method: POST
uri: http://parity-provider.invalid/v1/ocr
response:
body:
string: '{"pages":[{"index":0,"markdown":"invoice 123","images":[],"tables":[],"hyperlinks":[],"header":null,"footer":null,"dimensions":{"dpi":200,"height":300,"width":800},"confidence_scores":null,"blocks":[{"top_left_x":335,"top_left_y":138,"bottom_right_x":464,"bottom_right_y":162,"content":"invoice
123","confidence_scores":null,"type":"text"}]}],"model":"mistral-ocr-latest","document_annotation":null,"usage_info":{"pages_processed":1,"doc_size_bytes":4124}}'
headers:
CF-RAY:
- a346d36688d203c2-SJC
Content-Type:
- application/json
Date:
- Tue, 01 Sep 2026 19:54:15 GMT
Server:
- cloudflare
Strict-Transport-Security:
- max-age=15552000; includeSubDomains; preload
X-Content-Type-Options:
- nosniff
access-control-allow-origin:
- '*'
alt-svc:
- h3=":443"; ma=86400
cf-cache-status:
- DYNAMIC
mistral-correlation-id:
- 01a05e89-5c60-769b-abf3-44dcefdf5282
x-envoy-upstream-service-time:
- '272'
x-kong-proxy-latency:
- '17'
x-kong-request-id:
- 01a05e89-5c60-769b-abf3-44dcefdf5282
x-kong-upstream-latency:
- '273'
x-ratelimit-limit-ocr-pages-minute:
- '60'
x-ratelimit-ocr-pages-query-cost:
- '1'
x-ratelimit-remaining-ocr-pages-minute:
- '55'
status:
code: 200
message: ''
recorded_at: '2026-09-01T19:54:15.898087+00:00'
ttl_seconds: 0
version: 1
x-litellm:
case:
litellm_input:
contract: mistral
document:
image_url: https://dummyjson.com/image/800x300/ffffff/000000?text=invoice%20123&fontSize=24
type: image_url
image_min_size: 300
model: mistral/mistral-ocr-latest
request_source: python_replay
schema_version: 1

View file

@ -0,0 +1,68 @@
interactions:
- request:
body: '{"model":"mistral-ocr-latest","document":{"type":"image_url","image_url":"https://dummyjson.com/image/800x300/ffffff/000000?text=invoice%20123&fontSize=24"},"id":"case-1"}'
headers:
Accept:
- '*/*'
Content-Type:
- application/json
User-Agent:
- litellm/1.101.0
method: POST
uri: http://parity-provider.invalid/v1/ocr
response:
body:
string: '{"pages":[{"index":0,"markdown":"invoice 123","images":[],"tables":[],"hyperlinks":[],"header":null,"footer":null,"dimensions":{"dpi":200,"height":300,"width":800},"confidence_scores":null,"blocks":[{"top_left_x":335,"top_left_y":138,"bottom_right_x":464,"bottom_right_y":162,"content":"invoice
123","confidence_scores":null,"type":"text"}]}],"model":"mistral-ocr-latest","document_annotation":null,"usage_info":{"pages_processed":1,"doc_size_bytes":4124}}'
headers:
CF-RAY:
- a346d398da783ad4-SJC
Content-Type:
- application/json
Date:
- Tue, 01 Sep 2026 19:54:23 GMT
Server:
- cloudflare
Strict-Transport-Security:
- max-age=15552000; includeSubDomains; preload
X-Content-Type-Options:
- nosniff
access-control-allow-origin:
- '*'
alt-svc:
- h3=":443"; ma=86400
cf-cache-status:
- DYNAMIC
mistral-correlation-id:
- 01a05e89-7bdc-74c5-b8eb-15fbd25b6cfd
x-envoy-upstream-service-time:
- '182'
x-kong-proxy-latency:
- '22'
x-kong-request-id:
- 01a05e89-7bdc-74c5-b8eb-15fbd25b6cfd
x-kong-upstream-latency:
- '183'
x-ratelimit-limit-ocr-pages-minute:
- '60'
x-ratelimit-ocr-pages-query-cost:
- '1'
x-ratelimit-remaining-ocr-pages-minute:
- '46'
status:
code: 200
message: ''
recorded_at: '2026-09-01T19:54:23.946966+00:00'
ttl_seconds: 0
version: 1
x-litellm:
case:
litellm_input:
contract: mistral
document:
image_url: https://dummyjson.com/image/800x300/ffffff/000000?text=invoice%20123&fontSize=24
type: image_url
id: case-1
model: mistral/mistral-ocr-latest
request_source: python_replay
schema_version: 1

View file

@ -0,0 +1,67 @@
interactions:
- request:
body: '{"model":"mistral-ocr-latest","document":{"type":"image_url","image_url":"https://dummyjson.com/image/800x300/ffffff/000000?text=invoice%20123&fontSize=24"},"include_blocks":false}'
headers:
Accept:
- '*/*'
Content-Type:
- application/json
User-Agent:
- litellm/1.101.0
method: POST
uri: http://parity-provider.invalid/v1/ocr
response:
body:
string: '{"pages":[{"index":0,"markdown":"invoice 123","images":[],"tables":[],"hyperlinks":[],"header":null,"footer":null,"dimensions":{"dpi":200,"height":300,"width":800},"confidence_scores":null,"blocks":null}],"model":"mistral-ocr-latest","document_annotation":null,"usage_info":{"pages_processed":1,"doc_size_bytes":4124}}'
headers:
CF-RAY:
- a346d392a8432af7-SJC
Content-Type:
- application/json
Date:
- Tue, 01 Sep 2026 19:54:22 GMT
Server:
- cloudflare
Strict-Transport-Security:
- max-age=15552000; includeSubDomains; preload
X-Content-Type-Options:
- nosniff
access-control-allow-origin:
- '*'
alt-svc:
- h3=":443"; ma=86400
cf-cache-status:
- DYNAMIC
mistral-correlation-id:
- 01a05e89-7802-768c-bf57-9021a209ab48
x-envoy-upstream-service-time:
- '213'
x-kong-proxy-latency:
- '17'
x-kong-request-id:
- 01a05e89-7802-768c-bf57-9021a209ab48
x-kong-upstream-latency:
- '214'
x-ratelimit-limit-ocr-pages-minute:
- '60'
x-ratelimit-ocr-pages-query-cost:
- '1'
x-ratelimit-remaining-ocr-pages-minute:
- '47'
status:
code: 200
message: ''
recorded_at: '2026-09-01T19:54:23.442341+00:00'
ttl_seconds: 0
version: 1
x-litellm:
case:
litellm_input:
contract: mistral
document:
image_url: https://dummyjson.com/image/800x300/ffffff/000000?text=invoice%20123&fontSize=24
type: image_url
include_blocks: false
model: mistral/mistral-ocr-latest
request_source: python_replay
schema_version: 1

View file

@ -0,0 +1,68 @@
interactions:
- request:
body: '{"model":"mistral-ocr-latest","document":{"type":"image_url","image_url":"https://dummyjson.com/image/800x300/ffffff/000000?text=invoice%20123&fontSize=24"},"confidence_scores_granularity":"page"}'
headers:
Accept:
- '*/*'
Content-Type:
- application/json
User-Agent:
- litellm/1.101.0
method: POST
uri: http://parity-provider.invalid/v1/ocr
response:
body:
string: '{"pages":[{"index":0,"markdown":"invoice 123","images":[],"tables":[],"hyperlinks":[],"header":null,"footer":null,"dimensions":{"dpi":200,"height":300,"width":800},"confidence_scores":{"word_confidence_scores":[],"average_page_confidence_score":0.90845564554897,"minimum_page_confidence_score":0.16168208839823475},"blocks":[{"top_left_x":335,"top_left_y":138,"bottom_right_x":464,"bottom_right_y":162,"content":"invoice
123","confidence_scores":null,"type":"text"}]}],"model":"mistral-ocr-latest","document_annotation":null,"usage_info":{"pages_processed":1,"doc_size_bytes":4124}}'
headers:
CF-RAY:
- a346d38c59681749-SJC
Content-Type:
- application/json
Date:
- Tue, 01 Sep 2026 19:54:22 GMT
Server:
- cloudflare
Strict-Transport-Security:
- max-age=15552000; includeSubDomains; preload
X-Content-Type-Options:
- nosniff
access-control-allow-origin:
- '*'
alt-svc:
- h3=":443"; ma=86400
cf-cache-status:
- DYNAMIC
mistral-correlation-id:
- 01a05e89-7411-7d2c-9e43-01320e4cad1c
x-envoy-upstream-service-time:
- '329'
x-kong-proxy-latency:
- '14'
x-kong-request-id:
- 01a05e89-7411-7d2c-9e43-01320e4cad1c
x-kong-upstream-latency:
- '330'
x-ratelimit-limit-ocr-pages-minute:
- '60'
x-ratelimit-ocr-pages-query-cost:
- '1'
x-ratelimit-remaining-ocr-pages-minute:
- '48'
status:
code: 200
message: ''
recorded_at: '2026-09-01T19:54:22.437385+00:00'
ttl_seconds: 0
version: 1
x-litellm:
case:
litellm_input:
confidence_scores_granularity: page
contract: mistral
document:
image_url: https://dummyjson.com/image/800x300/ffffff/000000?text=invoice%20123&fontSize=24
type: image_url
model: mistral/mistral-ocr-latest
request_source: python_replay
schema_version: 1

View file

@ -0,0 +1,68 @@
interactions:
- request:
body: '{"model":"mistral-ocr-latest","document":{"type":"image_url","image_url":"https://dummyjson.com/image/800x300/ffffff/000000?text=invoice%20123&fontSize=24"},"extract_footer":true}'
headers:
Accept:
- '*/*'
Content-Type:
- application/json
User-Agent:
- litellm/1.101.0
method: POST
uri: http://parity-provider.invalid/v1/ocr
response:
body:
string: '{"pages":[{"index":0,"markdown":"invoice 123","images":[],"tables":[],"hyperlinks":[],"header":null,"footer":null,"dimensions":{"dpi":200,"height":300,"width":800},"confidence_scores":null,"blocks":[{"top_left_x":335,"top_left_y":138,"bottom_right_x":464,"bottom_right_y":162,"content":"invoice
123","confidence_scores":null,"type":"text"}]}],"model":"mistral-ocr-latest","document_annotation":null,"usage_info":{"pages_processed":1,"doc_size_bytes":4124}}'
headers:
CF-RAY:
- a346d37fde0f1703-SJC
Content-Type:
- application/json
Date:
- Tue, 01 Sep 2026 19:54:20 GMT
Server:
- cloudflare
Strict-Transport-Security:
- max-age=15552000; includeSubDomains; preload
X-Content-Type-Options:
- nosniff
access-control-allow-origin:
- '*'
alt-svc:
- h3=":443"; ma=86400
cf-cache-status:
- DYNAMIC
mistral-correlation-id:
- 01a05e89-6c42-7ea1-b08b-07cae50734e0
x-envoy-upstream-service-time:
- '523'
x-kong-proxy-latency:
- '13'
x-kong-request-id:
- 01a05e89-6c42-7ea1-b08b-07cae50734e0
x-kong-upstream-latency:
- '525'
x-ratelimit-limit-ocr-pages-minute:
- '60'
x-ratelimit-ocr-pages-query-cost:
- '1'
x-ratelimit-remaining-ocr-pages-minute:
- '50'
status:
code: 200
message: ''
recorded_at: '2026-09-01T19:54:20.425444+00:00'
ttl_seconds: 0
version: 1
x-litellm:
case:
litellm_input:
contract: mistral
document:
image_url: https://dummyjson.com/image/800x300/ffffff/000000?text=invoice%20123&fontSize=24
type: image_url
extract_footer: true
model: mistral/mistral-ocr-latest
request_source: python_replay
schema_version: 1

View file

@ -0,0 +1,83 @@
interactions:
- request:
body: '{"model":"mistral-ocr-latest","document":{"type":"document_url","document_url":"data:application/pdf;base64,JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvUmVzb3VyY2VzIDQgMCBSIC9NZWRpYUJveCBbMCAwIDYxMiA3OTJdIC9Db250ZW50cyA1IDAgUiA+PgplbmRvYmoKNCAwIG9iago8PCAvRm9udCA8PCAvRjEgPDwgL1R5cGUgL0ZvbnQgL1N1YnR5cGUgL1R5cGUxIC9CYXNlRm9udCAvSGVsdmV0aWNhID4+ID4+ID4+CmVuZG9iago1IDAgb2JqCjw8IC9MZW5ndGggNDQgPj4Kc3RyZWFtCkJUCi9GMSAxMiBUZgoxMDAgNzAwIFRkCihUZXN0IFBERiBGaWxlKSBUagpFVAplbmRzdHJlYW0KZW5kb2JqCnhyZWYKMCA2CjAwMDAwMDAwMDAgNjU1MzUgZiAKMDAwMDAwMDAwOSAwMDAwMCBuIAowMDAwMDAwMDU4IDAwMDAwIG4gCjAwMDAwMDAxMTUgMDAwMDAgbiAKMDAwMDAwMDIxNCAwMDAwMCBuIAowMDAwMDAwMjkzIDAwMDAwIG4gCnRyYWlsZXIKPDwgL1NpemUgNiAvUm9vdCAxIDAgUiA+PgpzdGFydHhyZWYKMzg1CiUlRU9GCg=="},"document_annotation_format":{"type":"json_schema","json_schema":{"name":"document_title","description":"Extract
the visible document fields","schema":{"additionalProperties":false,"properties":{"title":{"type":"string"}},"required":["title"],"type":"object"},"strict":true}}}'
headers:
Accept:
- '*/*'
Content-Type:
- application/json
User-Agent:
- litellm/1.101.0
method: POST
uri: http://parity-provider.invalid/v1/ocr
response:
body:
string: '{"pages":[{"index":0,"markdown":"Test PDF File","images":[],"tables":[],"hyperlinks":[],"header":null,"footer":null,"dimensions":{"dpi":93,"height":1023,"width":791},"confidence_scores":null,"blocks":[{"top_left_x":126,"top_left_y":104,"bottom_right_x":229,"bottom_right_y":127,"content":"Test
PDF File","confidence_scores":null,"type":"text"}]}],"model":"mistral-ocr-latest","document_annotation":"{\"title\":
\"Test_PDF_File\"}","usage_info":{"pages_processed":1,"doc_size_bytes":589}}'
headers:
CF-RAY:
- a346d3a25d0cccb8-SJC
Content-Type:
- application/json
Date:
- Tue, 01 Sep 2026 19:54:25 GMT
Server:
- cloudflare
Strict-Transport-Security:
- max-age=15552000; includeSubDomains; preload
X-Content-Type-Options:
- nosniff
access-control-allow-origin:
- '*'
alt-svc:
- h3=":443"; ma=86400
cf-cache-status:
- DYNAMIC
mistral-correlation-id:
- 01a05e89-81d5-79f8-914d-e3863af2d24b
x-envoy-upstream-service-time:
- '448'
x-kong-proxy-latency:
- '15'
x-kong-request-id:
- 01a05e89-81d5-79f8-914d-e3863af2d24b
x-kong-upstream-latency:
- '449'
x-ratelimit-limit-ocr-pages-minute:
- '60'
x-ratelimit-ocr-pages-query-cost:
- '1'
x-ratelimit-remaining-ocr-pages-minute:
- '44'
status:
code: 200
message: ''
recorded_at: '2026-09-01T19:54:25.959391+00:00'
ttl_seconds: 0
version: 1
x-litellm:
case:
litellm_input:
contract: mistral
document:
document_url: data:application/pdf;base64,JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvUmVzb3VyY2VzIDQgMCBSIC9NZWRpYUJveCBbMCAwIDYxMiA3OTJdIC9Db250ZW50cyA1IDAgUiA+PgplbmRvYmoKNCAwIG9iago8PCAvRm9udCA8PCAvRjEgPDwgL1R5cGUgL0ZvbnQgL1N1YnR5cGUgL1R5cGUxIC9CYXNlRm9udCAvSGVsdmV0aWNhID4+ID4+ID4+CmVuZG9iago1IDAgb2JqCjw8IC9MZW5ndGggNDQgPj4Kc3RyZWFtCkJUCi9GMSAxMiBUZgoxMDAgNzAwIFRkCihUZXN0IFBERiBGaWxlKSBUagpFVAplbmRzdHJlYW0KZW5kb2JqCnhyZWYKMCA2CjAwMDAwMDAwMDAgNjU1MzUgZiAKMDAwMDAwMDAwOSAwMDAwMCBuIAowMDAwMDAwMDU4IDAwMDAwIG4gCjAwMDAwMDAxMTUgMDAwMDAgbiAKMDAwMDAwMDIxNCAwMDAwMCBuIAowMDAwMDAwMjkzIDAwMDAwIG4gCnRyYWlsZXIKPDwgL1NpemUgNiAvUm9vdCAxIDAgUiA+PgpzdGFydHhyZWYKMzg1CiUlRU9GCg==
type: document_url
document_annotation_format:
json_schema:
description: Extract the visible document fields
name: document_title
schema:
additionalProperties: false
properties:
title:
type: string
required:
- title
type: object
strict: true
type: json_schema
model: mistral/mistral-ocr-latest
request_source: python_replay
schema_version: 1

View file

@ -0,0 +1,68 @@
interactions:
- request:
body: '{"model":"mistral-ocr-latest","document":{"type":"image_url","image_url":"https://dummyjson.com/image/800x300/ffffff/000000?text=invoice%20123&fontSize=24"},"table_format":"markdown"}'
headers:
Accept:
- '*/*'
Content-Type:
- application/json
User-Agent:
- litellm/1.101.0
method: POST
uri: http://parity-provider.invalid/v1/ocr
response:
body:
string: '{"pages":[{"index":0,"markdown":"invoice 123","images":[],"tables":[],"hyperlinks":[],"header":null,"footer":null,"dimensions":{"dpi":200,"height":300,"width":800},"confidence_scores":null,"blocks":[{"top_left_x":335,"top_left_y":138,"bottom_right_x":464,"bottom_right_y":162,"content":"invoice
123","confidence_scores":null,"type":"text"}]}],"model":"mistral-ocr-latest","document_annotation":null,"usage_info":{"pages_processed":1,"doc_size_bytes":4124}}'
headers:
CF-RAY:
- a346d385ffbda0f2-SJC
Content-Type:
- application/json
Date:
- Tue, 01 Sep 2026 19:54:21 GMT
Server:
- cloudflare
Strict-Transport-Security:
- max-age=15552000; includeSubDomains; preload
X-Content-Type-Options:
- nosniff
access-control-allow-origin:
- '*'
alt-svc:
- h3=":443"; ma=86400
cf-cache-status:
- DYNAMIC
mistral-correlation-id:
- 01a05e89-7015-7a8d-a18c-37b9ceacbe77
x-envoy-upstream-service-time:
- '337'
x-kong-proxy-latency:
- '21'
x-kong-request-id:
- 01a05e89-7015-7a8d-a18c-37b9ceacbe77
x-kong-upstream-latency:
- '338'
x-ratelimit-limit-ocr-pages-minute:
- '60'
x-ratelimit-ocr-pages-query-cost:
- '1'
x-ratelimit-remaining-ocr-pages-minute:
- '49'
status:
code: 200
message: ''
recorded_at: '2026-09-01T19:54:21.430472+00:00'
ttl_seconds: 0
version: 1
x-litellm:
case:
litellm_input:
contract: mistral
document:
image_url: https://dummyjson.com/image/800x300/ffffff/000000?text=invoice%20123&fontSize=24
type: image_url
model: mistral/mistral-ocr-latest
table_format: markdown
request_source: python_replay
schema_version: 1

View file

@ -0,0 +1,105 @@
interactions:
- request:
body: '{"model":"mistral-ocr-latest","document":{"type":"image_url","image_url":"https://dummyjson.com/image/800x300/ffffff/000000?text=invoice%20123&fontSize=24"},"pages":[0],"include_image_base64":false,"image_min_size":300,"bbox_annotation_format":{"type":"json_schema","json_schema":{"name":"bounding_boxes","description":"Extract
the visible document fields","schema":{"additionalProperties":false,"properties":{"title":{"type":"string"}},"required":["title"],"type":"object"},"strict":true}},"document_annotation_format":{"type":"json_schema","json_schema":{"name":"document_title","description":"Extract
the visible document fields","schema":{"additionalProperties":false,"properties":{"title":{"type":"string"}},"required":["title"],"type":"object"},"strict":true}},"extract_header":true,"table_format":"markdown","include_blocks":true}'
headers:
Accept:
- '*/*'
Content-Type:
- application/json
User-Agent:
- litellm/1.101.0
method: POST
uri: http://parity-provider.invalid/v1/ocr
response:
body:
string: '{"pages":[{"index":0,"markdown":"invoice 123","images":[],"tables":[],"hyperlinks":[],"header":null,"footer":null,"dimensions":{"dpi":200,"height":300,"width":800},"confidence_scores":null,"blocks":[{"top_left_x":335,"top_left_y":138,"bottom_right_x":464,"bottom_right_y":162,"content":"invoice
123","confidence_scores":null,"type":"text"}]}],"model":"mistral-ocr-latest","document_annotation":"{\"title\":
\"Invoice_123\"}","usage_info":{"pages_processed":1,"doc_size_bytes":4124}}'
headers:
CF-RAY:
- a346d3a89a85f953-SJC
Content-Type:
- application/json
Date:
- Tue, 01 Sep 2026 19:54:26 GMT
Server:
- cloudflare
Strict-Transport-Security:
- max-age=15552000; includeSubDomains; preload
X-Content-Type-Options:
- nosniff
access-control-allow-origin:
- '*'
alt-svc:
- h3=":443"; ma=86400
cf-cache-status:
- DYNAMIC
mistral-correlation-id:
- 01a05e89-85bd-7075-af53-8e341b35a63e
x-envoy-upstream-service-time:
- '432'
x-kong-proxy-latency:
- '13'
x-kong-request-id:
- 01a05e89-85bd-7075-af53-8e341b35a63e
x-kong-upstream-latency:
- '433'
x-ratelimit-limit-ocr-pages-minute:
- '60'
x-ratelimit-ocr-pages-query-cost:
- '1'
x-ratelimit-remaining-ocr-pages-minute:
- '43'
status:
code: 200
message: ''
recorded_at: '2026-09-01T19:54:26.965078+00:00'
ttl_seconds: 0
version: 1
x-litellm:
case:
litellm_input:
bbox_annotation_format:
json_schema:
description: Extract the visible document fields
name: bounding_boxes
schema:
additionalProperties: false
properties:
title:
type: string
required:
- title
type: object
strict: true
type: json_schema
contract: mistral
document:
image_url: https://dummyjson.com/image/800x300/ffffff/000000?text=invoice%20123&fontSize=24
type: image_url
document_annotation_format:
json_schema:
description: Extract the visible document fields
name: document_title
schema:
additionalProperties: false
properties:
title:
type: string
required:
- title
type: object
strict: true
type: json_schema
extract_header: true
image_min_size: 300
include_blocks: true
include_image_base64: false
model: mistral/mistral-ocr-latest
pages:
- 0
table_format: markdown
request_source: python_replay
schema_version: 1

View file

@ -0,0 +1,70 @@
interactions:
- request:
body: "--3d54cb2e388c04dbfc172c1497aaa396\r\nContent-Disposition: form-data; name=\"file\";
filename=\"document\"\r\nContent-Type: application/pdf\r\n\r\n%PDF-1.4\n1 0
obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n2 0 obj\n<< /Type /Pages /Kids
[3 0 R] /Count 1 >>\nendobj\n3 0 obj\n<< /Type /Page /Parent 2 0 R /Resources
4 0 R /MediaBox [0 0 612 792] /Contents 5 0 R >>\nendobj\n4 0 obj\n<< /Font
<< /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> >>\nendobj\n5
0 obj\n<< /Length 44 >>\nstream\nBT\n/F1 12 Tf\n100 700 Td\n(Test PDF File)
Tj\nET\nendstream\nendobj\nxref\n0 6\n0000000000 65535 f \n0000000009 00000
n \n0000000058 00000 n \n0000000115 00000 n \n0000000214 00000 n \n0000000293
00000 n \ntrailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n385\n%%EOF\n\r\n--3d54cb2e388c04dbfc172c1497aaa396--\r\n"
headers:
Accept:
- '*/*'
Content-Type:
- multipart/form-data; boundary=3d54cb2e388c04dbfc172c1497aaa396
User-Agent:
- litellm/1.101.0
method: POST
uri: http://parity-provider.invalid/upload
response:
body:
string: '{"file_id":"reducto://45d3cbbc-4d77-4967-8941-935b0c4a0493.pdf","presigned_url":null}'
headers:
Content-Type:
- application/json
Date:
- Tue, 01 Sep 2026 19:54:58 GMT
status:
code: 200
message: ''
- request:
body: '{"document_url":"reducto://45d3cbbc-4d77-4967-8941-935b0c4a0493.pdf"}'
headers:
Accept:
- '*/*'
Content-Type:
- application/json
User-Agent:
- litellm/1.101.0
method: POST
uri: http://parity-provider.invalid/parse
response:
body:
string: '{"response_type":"parse","job_id":"262ed683-ecd8-40b9-a568-f7b7014854c9","duration":2.4146597385406494,"pdf_url":"https://prod-storage20241010144745140900000001.s3.amazonaws.com/org/8bd983d1-47dd-440b-93d8-431412d819bf/a91f568b-6e98-4962-9924-097202093779.pdf?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIA2UOK6OVBOUYL7WYA%2F20260901%2Fus-west-2%2Fs3%2Faws4_request&X-Amz-Date=20260901T195456Z&X-Amz-Expires=43200&X-Amz-SignedHeaders=host&X-Amz-Signature=aa77b33e20fc1426307688f55147d57e8d3b2286a787c096b2da016d7d7d0c93","studio_link":"https://studio.reducto.ai/job/cddd635e-5d16-4621-bf48-031e7637dc0b","usage":{"num_pages":1,"credits":1.0,"credit_breakdown":{"page":1.0},"page_billing_breakdown":{"1":["page"]},"non_empty_cell_count":null},"result":{"type":"full","chunks":[{"content":"#
Test PDF File","embed":"# Test PDF File","enriched":null,"enrichment_success":false,"blocks":[{"type":"Title","bbox":{"left":0.16352969387356803,"top":0.10490237663507056,"width":0.11855640077405508,"height":0.011359045881442221,"page":1,"original_page":1},"content":"Test
PDF File","image_url":null,"chart_data":null,"confidence":"high","granular_confidence":{"extract_confidence":null,"parse_confidence":0.7126715332269669},"extra":null}]}],"ocr":null,"custom":null},"parse_mode":null,"document_properties":null}'
headers:
Content-Type:
- application/json
Date:
- Tue, 01 Sep 2026 19:54:59 GMT
status:
code: 200
message: ''
recorded_at: '2026-09-01T19:54:59.198829+00:00'
ttl_seconds: 0
version: 1
x-litellm:
case:
litellm_input:
contract: reducto_legacy
custom_llm_provider: reducto
document:
document_url: data:application/pdf;base64,JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvUmVzb3VyY2VzIDQgMCBSIC9NZWRpYUJveCBbMCAwIDYxMiA3OTJdIC9Db250ZW50cyA1IDAgUiA+PgplbmRvYmoKNCAwIG9iago8PCAvRm9udCA8PCAvRjEgPDwgL1R5cGUgL0ZvbnQgL1N1YnR5cGUgL1R5cGUxIC9CYXNlRm9udCAvSGVsdmV0aWNhID4+ID4+ID4+CmVuZG9iago1IDAgb2JqCjw8IC9MZW5ndGggNDQgPj4Kc3RyZWFtCkJUCi9GMSAxMiBUZgoxMDAgNzAwIFRkCihUZXN0IFBERiBGaWxlKSBUagpFVAplbmRzdHJlYW0KZW5kb2JqCnhyZWYKMCA2CjAwMDAwMDAwMDAgNjU1MzUgZiAKMDAwMDAwMDAwOSAwMDAwMCBuIAowMDAwMDAwMDU4IDAwMDAwIG4gCjAwMDAwMDAxMTUgMDAwMDAgbiAKMDAwMDAwMDIxNCAwMDAwMCBuIAowMDAwMDAwMjkzIDAwMDAwIG4gCnRyYWlsZXIKPDwgL1NpemUgNiAvUm9vdCAxIDAgUiA+PgpzdGFydHhyZWYKMzg1CiUlRU9GCg==
type: document_url
model: parse-legacy
request_source: python_replay
schema_version: 1

View file

@ -0,0 +1,39 @@
interactions:
- request:
body: '{"document_url":"reducto://invalid-document-for-parity"}'
headers:
Accept:
- '*/*'
Content-Type:
- application/json
User-Agent:
- litellm/1.101.0
method: POST
uri: http://parity-provider.invalid/parse
response:
body:
string: '{"error":{"code":404,"name":"NOT_FOUND","message":"Document ''The file
may have expired or been deleted. Please re-upload and try again.'' not found"},"detail":"Document
''The file may have expired or been deleted. Please re-upload and try again.''
not found"}'
headers:
Content-Type:
- application/json
Date:
- Wed, 02 Sep 2026 01:14:06 GMT
status:
code: 404
message: ''
recorded_at: '2026-09-02T01:14:06.814847+00:00'
ttl_seconds: 0
version: 1
x-litellm:
case:
litellm_input:
contract: reducto_legacy
document:
document_url: reducto://invalid-document-for-parity
type: document_url
model: reducto/parse-legacy
request_source: python_replay
schema_version: 1

View file

@ -0,0 +1,70 @@
interactions:
- request:
body: "--90e8e7e6a71b2a4b14695234a736d695\r\nContent-Disposition: form-data; name=\"file\";
filename=\"document\"\r\nContent-Type: application/pdf\r\n\r\n%PDF-1.4\n1 0
obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n2 0 obj\n<< /Type /Pages /Kids
[3 0 R] /Count 1 >>\nendobj\n3 0 obj\n<< /Type /Page /Parent 2 0 R /Resources
4 0 R /MediaBox [0 0 612 792] /Contents 5 0 R >>\nendobj\n4 0 obj\n<< /Font
<< /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> >>\nendobj\n5
0 obj\n<< /Length 44 >>\nstream\nBT\n/F1 12 Tf\n100 700 Td\n(Test PDF File)
Tj\nET\nendstream\nendobj\nxref\n0 6\n0000000000 65535 f \n0000000009 00000
n \n0000000058 00000 n \n0000000115 00000 n \n0000000214 00000 n \n0000000293
00000 n \ntrailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n385\n%%EOF\n\r\n--90e8e7e6a71b2a4b14695234a736d695--\r\n"
headers:
Accept:
- '*/*'
Content-Type:
- multipart/form-data; boundary=90e8e7e6a71b2a4b14695234a736d695
User-Agent:
- litellm/1.101.0
method: POST
uri: http://parity-provider.invalid/upload
response:
body:
string: '{"file_id":"reducto://b9f242fd-fdb4-4b0a-9535-f11f432c7678.pdf","presigned_url":null}'
headers:
Content-Type:
- application/json
Date:
- Tue, 01 Sep 2026 19:54:57 GMT
status:
code: 200
message: ''
- request:
body: '{"document_url":"reducto://b9f242fd-fdb4-4b0a-9535-f11f432c7678.pdf","options":{"enhance":{}}}'
headers:
Accept:
- '*/*'
Content-Type:
- application/json
User-Agent:
- litellm/1.101.0
method: POST
uri: http://parity-provider.invalid/parse
response:
body:
string: '{"response_type":"parse","job_id":"25b78189-66fe-46d9-8114-14372a9d442d","duration":2.4146597385406494,"pdf_url":"https://prod-storage20241010144745140900000001.s3.amazonaws.com/org/8bd983d1-47dd-440b-93d8-431412d819bf/a91f568b-6e98-4962-9924-097202093779.pdf?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIA2UOK6OVBOUYL7WYA%2F20260901%2Fus-west-2%2Fs3%2Faws4_request&X-Amz-Date=20260901T195456Z&X-Amz-Expires=43200&X-Amz-SignedHeaders=host&X-Amz-Signature=aa77b33e20fc1426307688f55147d57e8d3b2286a787c096b2da016d7d7d0c93","studio_link":"https://studio.reducto.ai/job/cddd635e-5d16-4621-bf48-031e7637dc0b","usage":{"num_pages":1,"credits":1.0,"credit_breakdown":{"page":1.0},"page_billing_breakdown":{"1":["page"]},"non_empty_cell_count":null},"result":{"type":"full","chunks":[{"content":"#
Test PDF File","embed":"# Test PDF File","enriched":null,"enrichment_success":false,"blocks":[{"type":"Title","bbox":{"left":0.16352969387356803,"top":0.10490237663507056,"width":0.11855640077405508,"height":0.011359045881442221,"page":1,"original_page":1},"content":"Test
PDF File","image_url":null,"chart_data":null,"confidence":"high","granular_confidence":{"extract_confidence":null,"parse_confidence":0.7126715332269669},"extra":null}]}],"ocr":null,"custom":null},"parse_mode":null,"document_properties":null}'
headers:
Content-Type:
- application/json
Date:
- Tue, 01 Sep 2026 19:54:57 GMT
status:
code: 200
message: ''
recorded_at: '2026-09-01T19:54:58.471820+00:00'
ttl_seconds: 0
version: 1
x-litellm:
case:
litellm_input:
contract: reducto_legacy
document:
document_url: data:application/pdf;base64,JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvUmVzb3VyY2VzIDQgMCBSIC9NZWRpYUJveCBbMCAwIDYxMiA3OTJdIC9Db250ZW50cyA1IDAgUiA+PgplbmRvYmoKNCAwIG9iago8PCAvRm9udCA8PCAvRjEgPDwgL1R5cGUgL0ZvbnQgL1N1YnR5cGUgL1R5cGUxIC9CYXNlRm9udCAvSGVsdmV0aWNhID4+ID4+ID4+CmVuZG9iago1IDAgb2JqCjw8IC9MZW5ndGggNDQgPj4Kc3RyZWFtCkJUCi9GMSAxMiBUZgoxMDAgNzAwIFRkCihUZXN0IFBERiBGaWxlKSBUagpFVAplbmRzdHJlYW0KZW5kb2JqCnhyZWYKMCA2CjAwMDAwMDAwMDAgNjU1MzUgZiAKMDAwMDAwMDAwOSAwMDAwMCBuIAowMDAwMDAwMDU4IDAwMDAwIG4gCjAwMDAwMDAxMTUgMDAwMDAgbiAKMDAwMDAwMDIxNCAwMDAwMCBuIAowMDAwMDAwMjkzIDAwMDAwIG4gCnRyYWlsZXIKPDwgL1NpemUgNiAvUm9vdCAxIDAgUiA+PgpzdGFydHhyZWYKMzg1CiUlRU9GCg==
type: document_url
enhance: {}
model: reducto/parse-legacy
request_source: python_replay
schema_version: 1

View file

@ -0,0 +1,69 @@
interactions:
- request:
body: "--87aea09e72c511f78d32f6eabd194f5c\r\nContent-Disposition: form-data; name=\"file\";
filename=\"document\"\r\nContent-Type: application/pdf\r\n\r\n%PDF-1.4\n1 0
obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n2 0 obj\n<< /Type /Pages /Kids
[3 0 R] /Count 1 >>\nendobj\n3 0 obj\n<< /Type /Page /Parent 2 0 R /Resources
4 0 R /MediaBox [0 0 612 792] /Contents 5 0 R >>\nendobj\n4 0 obj\n<< /Font
<< /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> >>\nendobj\n5
0 obj\n<< /Length 44 >>\nstream\nBT\n/F1 12 Tf\n100 700 Td\n(Test PDF File)
Tj\nET\nendstream\nendobj\nxref\n0 6\n0000000000 65535 f \n0000000009 00000
n \n0000000058 00000 n \n0000000115 00000 n \n0000000214 00000 n \n0000000293
00000 n \ntrailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n385\n%%EOF\n\r\n--87aea09e72c511f78d32f6eabd194f5c--\r\n"
headers:
Accept:
- '*/*'
Content-Type:
- multipart/form-data; boundary=87aea09e72c511f78d32f6eabd194f5c
User-Agent:
- litellm/1.101.0
method: POST
uri: http://parity-provider.invalid/upload
response:
body:
string: '{"file_id":"reducto://a91f568b-6e98-4962-9924-097202093779.pdf","presigned_url":null}'
headers:
Content-Type:
- application/json
Date:
- Tue, 01 Sep 2026 19:54:53 GMT
status:
code: 200
message: ''
- request:
body: '{"document_url":"reducto://a91f568b-6e98-4962-9924-097202093779.pdf"}'
headers:
Accept:
- '*/*'
Content-Type:
- application/json
User-Agent:
- litellm/1.101.0
method: POST
uri: http://parity-provider.invalid/parse
response:
body:
string: '{"response_type":"parse","job_id":"cddd635e-5d16-4621-bf48-031e7637dc0b","duration":2.4146597385406494,"pdf_url":"https://prod-storage20241010144745140900000001.s3.amazonaws.com/org/8bd983d1-47dd-440b-93d8-431412d819bf/a91f568b-6e98-4962-9924-097202093779.pdf?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIA2UOK6OVBOUYL7WYA%2F20260901%2Fus-west-2%2Fs3%2Faws4_request&X-Amz-Date=20260901T195456Z&X-Amz-Expires=43200&X-Amz-SignedHeaders=host&X-Amz-Signature=aa77b33e20fc1426307688f55147d57e8d3b2286a787c096b2da016d7d7d0c93","studio_link":"https://studio.reducto.ai/job/cddd635e-5d16-4621-bf48-031e7637dc0b","usage":{"num_pages":1,"credits":1.0,"credit_breakdown":{"page":1.0},"page_billing_breakdown":{"1":["page"]},"non_empty_cell_count":null},"result":{"type":"full","chunks":[{"content":"#
Test PDF File","embed":"# Test PDF File","enriched":null,"enrichment_success":false,"blocks":[{"type":"Title","bbox":{"left":0.16352969387356803,"top":0.10490237663507056,"width":0.11855640077405508,"height":0.011359045881442221,"page":1,"original_page":1},"content":"Test
PDF File","image_url":null,"chart_data":null,"confidence":"high","granular_confidence":{"extract_confidence":null,"parse_confidence":0.7126715332269669},"extra":null}]}],"ocr":null,"custom":null},"parse_mode":null,"document_properties":null}'
headers:
Content-Type:
- application/json
Date:
- Tue, 01 Sep 2026 19:54:57 GMT
status:
code: 200
message: ''
recorded_at: '2026-09-01T19:54:57.246119+00:00'
ttl_seconds: 0
version: 1
x-litellm:
case:
litellm_input:
contract: reducto_legacy
document:
document_url: data:application/pdf;base64,JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvUmVzb3VyY2VzIDQgMCBSIC9NZWRpYUJveCBbMCAwIDYxMiA3OTJdIC9Db250ZW50cyA1IDAgUiA+PgplbmRvYmoKNCAwIG9iago8PCAvRm9udCA8PCAvRjEgPDwgL1R5cGUgL0ZvbnQgL1N1YnR5cGUgL1R5cGUxIC9CYXNlRm9udCAvSGVsdmV0aWNhID4+ID4+ID4+CmVuZG9iago1IDAgb2JqCjw8IC9MZW5ndGggNDQgPj4Kc3RyZWFtCkJUCi9GMSAxMiBUZgoxMDAgNzAwIFRkCihUZXN0IFBERiBGaWxlKSBUagpFVAplbmRzdHJlYW0KZW5kb2JqCnhyZWYKMCA2CjAwMDAwMDAwMDAgNjU1MzUgZiAKMDAwMDAwMDAwOSAwMDAwMCBuIAowMDAwMDAwMDU4IDAwMDAwIG4gCjAwMDAwMDAxMTUgMDAwMDAgbiAKMDAwMDAwMDIxNCAwMDAwMCBuIAowMDAwMDAwMjkzIDAwMDAwIG4gCnRyYWlsZXIKPDwgL1NpemUgNiAvUm9vdCAxIDAgUiA+PgpzdGFydHhyZWYKMzg1CiUlRU9GCg==
type: document_url
model: reducto/parse-legacy
request_source: python_replay
schema_version: 1

View file

@ -0,0 +1,72 @@
interactions:
- request:
body: "--3c37fd2676c46ae1ff34a706baa70fad\r\nContent-Disposition: form-data; name=\"file\";
filename=\"document\"\r\nContent-Type: application/pdf\r\n\r\n%PDF-1.4\n1 0
obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n2 0 obj\n<< /Type /Pages /Kids
[3 0 R] /Count 1 >>\nendobj\n3 0 obj\n<< /Type /Page /Parent 2 0 R /Resources
4 0 R /MediaBox [0 0 612 792] /Contents 5 0 R >>\nendobj\n4 0 obj\n<< /Font
<< /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> >>\nendobj\n5
0 obj\n<< /Length 44 >>\nstream\nBT\n/F1 12 Tf\n100 700 Td\n(Test PDF File)
Tj\nET\nendstream\nendobj\nxref\n0 6\n0000000000 65535 f \n0000000009 00000
n \n0000000058 00000 n \n0000000115 00000 n \n0000000214 00000 n \n0000000293
00000 n \ntrailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n385\n%%EOF\n\r\n--3c37fd2676c46ae1ff34a706baa70fad--\r\n"
headers:
Accept:
- '*/*'
Content-Type:
- multipart/form-data; boundary=3c37fd2676c46ae1ff34a706baa70fad
User-Agent:
- litellm/1.101.0
method: POST
uri: http://parity-provider.invalid/upload
response:
body:
string: '{"file_id":"reducto://671d6e00-6df5-493a-bd9e-8bbf3d77d3ab.pdf","presigned_url":null}'
headers:
Content-Type:
- application/json
Date:
- Tue, 01 Sep 2026 19:54:36 GMT
status:
code: 200
message: ''
- request:
body: '{"input":"reducto://671d6e00-6df5-493a-bd9e-8bbf3d77d3ab.pdf","retrieval":{"chunking":{"chunk_mode":"page"}}}'
headers:
Accept:
- '*/*'
Content-Type:
- application/json
User-Agent:
- litellm/1.101.0
method: POST
uri: http://parity-provider.invalid/parse
response:
body:
string: '{"response_type":"parse","job_id":"8f15b6dd-f2d2-48e2-a4c9-0230a9ace704","duration":3.7510643005371094,"pdf_url":"https://prod-storage20241010144745140900000001.s3.amazonaws.com/org/8bd983d1-47dd-440b-93d8-431412d819bf/671d6e00-6df5-493a-bd9e-8bbf3d77d3ab.pdf?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIA2UOK6OVBOUYL7WYA%2F20260901%2Fus-west-2%2Fs3%2Faws4_request&X-Amz-Date=20260901T195440Z&X-Amz-Expires=43200&X-Amz-SignedHeaders=host&X-Amz-Signature=96a4f904e8d3ca58d399ab21ade69ec0ea62ff5dfee320c5eeaa6eaa0c4e0561","studio_link":"https://studio.reducto.ai/job/8f15b6dd-f2d2-48e2-a4c9-0230a9ace704","usage":{"num_pages":1,"credits":1.0,"credit_breakdown":{"page":1.0},"page_billing_breakdown":{"1":["page"]},"non_empty_cell_count":null},"result":{"type":"full","chunks":[{"content":"#
Test PDF File","embed":"# Test PDF File","enriched":null,"enrichment_success":false,"blocks":[{"type":"Title","bbox":{"left":0.16189574527182748,"top":0.09479295553814121,"width":0.11946290650820875,"height":0.02146414301710507,"page":1,"original_page":1},"content":"Test
PDF File","image_url":null,"chart_data":null,"confidence":"high","granular_confidence":{"extract_confidence":null,"parse_confidence":0.7185355693101882},"extra":null}]}],"ocr":null,"custom":null},"parse_mode":null,"document_properties":null}'
headers:
Content-Type:
- application/json
Date:
- Tue, 01 Sep 2026 19:54:40 GMT
status:
code: 200
message: ''
recorded_at: '2026-09-01T19:54:41.233002+00:00'
ttl_seconds: 0
version: 1
x-litellm:
case:
litellm_input:
contract: reducto_v3
document:
document_url: data:application/pdf;base64,JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvUmVzb3VyY2VzIDQgMCBSIC9NZWRpYUJveCBbMCAwIDYxMiA3OTJdIC9Db250ZW50cyA1IDAgUiA+PgplbmRvYmoKNCAwIG9iago8PCAvRm9udCA8PCAvRjEgPDwgL1R5cGUgL0ZvbnQgL1N1YnR5cGUgL1R5cGUxIC9CYXNlRm9udCAvSGVsdmV0aWNhID4+ID4+ID4+CmVuZG9iago1IDAgb2JqCjw8IC9MZW5ndGggNDQgPj4Kc3RyZWFtCkJUCi9GMSAxMiBUZgoxMDAgNzAwIFRkCihUZXN0IFBERiBGaWxlKSBUagpFVAplbmRzdHJlYW0KZW5kb2JqCnhyZWYKMCA2CjAwMDAwMDAwMDAgNjU1MzUgZiAKMDAwMDAwMDAwOSAwMDAwMCBuIAowMDAwMDAwMDU4IDAwMDAwIG4gCjAwMDAwMDAxMTUgMDAwMDAgbiAKMDAwMDAwMDIxNCAwMDAwMCBuIAowMDAwMDAwMjkzIDAwMDAwIG4gCnRyYWlsZXIKPDwgL1NpemUgNiAvUm9vdCAxIDAgUiA+PgpzdGFydHhyZWYKMzg1CiUlRU9GCg==
type: document_url
model: reducto/parse-v3
retrieval:
chunking:
chunk_mode: page
request_source: python_replay
schema_version: 1

View file

@ -0,0 +1,83 @@
interactions:
- request:
body: "--43159c32150fa5f506932c4b4a645ef6\r\nContent-Disposition: form-data; name=\"file\";
filename=\"document\"\r\nContent-Type: application/pdf\r\n\r\n%PDF-1.4\n1 0
obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n2 0 obj\n<< /Type /Pages /Kids
[3 0 R] /Count 1 >>\nendobj\n3 0 obj\n<< /Type /Page /Parent 2 0 R /Resources
4 0 R /MediaBox [0 0 612 792] /Contents 5 0 R >>\nendobj\n4 0 obj\n<< /Font
<< /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> >>\nendobj\n5
0 obj\n<< /Length 44 >>\nstream\nBT\n/F1 12 Tf\n100 700 Td\n(Test PDF File)
Tj\nET\nendstream\nendobj\nxref\n0 6\n0000000000 65535 f \n0000000009 00000
n \n0000000058 00000 n \n0000000115 00000 n \n0000000214 00000 n \n0000000293
00000 n \ntrailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n385\n%%EOF\n\r\n--43159c32150fa5f506932c4b4a645ef6--\r\n"
headers:
Accept:
- '*/*'
Content-Type:
- multipart/form-data; boundary=43159c32150fa5f506932c4b4a645ef6
User-Agent:
- litellm/1.101.0
method: POST
uri: http://parity-provider.invalid/upload
response:
body:
string: '{"file_id":"reducto://f8bf17e8-ca0b-4add-b484-9982d2e4ac2a.pdf","presigned_url":null}'
headers:
Content-Type:
- application/json
Date:
- Tue, 01 Sep 2026 19:54:50 GMT
status:
code: 200
message: ''
- request:
body: '{"input":"reducto://f8bf17e8-ca0b-4add-b484-9982d2e4ac2a.pdf","formatting":{"add_page_markers":true,"table_output_format":"json","merge_tables":true,"include":["change_tracking","highlight","comments"]}}'
headers:
Accept:
- '*/*'
Content-Type:
- application/json
User-Agent:
- litellm/1.101.0
method: POST
uri: http://parity-provider.invalid/parse
response:
body:
string: '{"response_type":"parse","job_id":"e3792387-8384-45aa-b02e-a84521db116a","duration":1.0497362613677979,"pdf_url":"https://prod-storage20241010144745140900000001.s3.amazonaws.com/org/8bd983d1-47dd-440b-93d8-431412d819bf/f8bf17e8-ca0b-4add-b484-9982d2e4ac2a.pdf?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIA2UOK6OVBOUYL7WYA%2F20260901%2Fus-west-2%2Fs3%2Faws4_request&X-Amz-Date=20260901T195452Z&X-Amz-Expires=43200&X-Amz-SignedHeaders=host&X-Amz-Signature=7367275a69dfd53f9fcf9f02777f0fe973e7e541fa1871e0347f788b8c5078f4","studio_link":"https://studio.reducto.ai/job/e3792387-8384-45aa-b02e-a84521db116a","usage":{"num_pages":1,"credits":1.0,"credit_breakdown":{"page":1.0},"page_billing_breakdown":{"1":["page"]},"non_empty_cell_count":null},"result":{"type":"full","chunks":[{"content":"[[START
OF PAGE 1]]\n\n# Test PDF File\n\n[[END OF PAGE 1]]","embed":"[[START OF PAGE
1]]\n\n# Test PDF File\n\n[[END OF PAGE 1]]","enriched":null,"enrichment_success":false,"blocks":[{"type":"Page
Number","bbox":{"left":0.0,"top":0.0,"width":0.0,"height":0.0,"page":1,"original_page":1},"content":"[[START
OF PAGE 1]]","image_url":null,"chart_data":null,"confidence":"high","granular_confidence":{"extract_confidence":null,"parse_confidence":null},"extra":null},{"type":"Title","bbox":{"left":0.16189574527182748,"top":0.09479295553814121,"width":0.11946290650820875,"height":0.02146414301710507,"page":1,"original_page":1},"content":"Test
PDF File","image_url":null,"chart_data":null,"confidence":"high","granular_confidence":{"extract_confidence":null,"parse_confidence":0.7185355693101882},"extra":null},{"type":"Page
Number","bbox":{"left":0.0,"top":0.0,"width":0.0,"height":0.0,"page":1,"original_page":1},"content":"[[END
OF PAGE 1]]","image_url":null,"chart_data":null,"confidence":"high","granular_confidence":{"extract_confidence":null,"parse_confidence":null},"extra":null}]}],"ocr":null,"custom":null},"parse_mode":null,"document_properties":null}'
headers:
Content-Type:
- application/json
Date:
- Tue, 01 Sep 2026 19:54:52 GMT
status:
code: 200
message: ''
recorded_at: '2026-09-01T19:54:53.454592+00:00'
ttl_seconds: 0
version: 1
x-litellm:
case:
litellm_input:
contract: reducto_v3
custom_llm_provider: null
document:
document_url: data:application/pdf;base64,JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvUmVzb3VyY2VzIDQgMCBSIC9NZWRpYUJveCBbMCAwIDYxMiA3OTJdIC9Db250ZW50cyA1IDAgUiA+PgplbmRvYmoKNCAwIG9iago8PCAvRm9udCA8PCAvRjEgPDwgL1R5cGUgL0ZvbnQgL1N1YnR5cGUgL1R5cGUxIC9CYXNlRm9udCAvSGVsdmV0aWNhID4+ID4+ID4+CmVuZG9iago1IDAgb2JqCjw8IC9MZW5ndGggNDQgPj4Kc3RyZWFtCkJUCi9GMSAxMiBUZgoxMDAgNzAwIFRkCihUZXN0IFBERiBGaWxlKSBUagpFVAplbmRzdHJlYW0KZW5kb2JqCnhyZWYKMCA2CjAwMDAwMDAwMDAgNjU1MzUgZiAKMDAwMDAwMDAwOSAwMDAwMCBuIAowMDAwMDAwMDU4IDAwMDAwIG4gCjAwMDAwMDAxMTUgMDAwMDAgbiAKMDAwMDAwMDIxNCAwMDAwMCBuIAowMDAwMDAwMjkzIDAwMDAwIG4gCnRyYWlsZXIKPDwgL1NpemUgNiAvUm9vdCAxIDAgUiA+PgpzdGFydHhyZWYKMzg1CiUlRU9GCg==
type: document_url
formatting:
add_page_markers: true
include:
- change_tracking
- highlight
- comments
merge_tables: true
table_output_format: json
model: reducto/parse-v3
request_source: python_replay
schema_version: 1

View file

@ -0,0 +1,70 @@
interactions:
- request:
body: "--ba392e7bb7694af286b0acfe46365a0f\r\nContent-Disposition: form-data; name=\"file\";
filename=\"document\"\r\nContent-Type: application/pdf\r\n\r\n%PDF-1.4\n1 0
obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n2 0 obj\n<< /Type /Pages /Kids
[3 0 R] /Count 1 >>\nendobj\n3 0 obj\n<< /Type /Page /Parent 2 0 R /Resources
4 0 R /MediaBox [0 0 612 792] /Contents 5 0 R >>\nendobj\n4 0 obj\n<< /Font
<< /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> >>\nendobj\n5
0 obj\n<< /Length 44 >>\nstream\nBT\n/F1 12 Tf\n100 700 Td\n(Test PDF File)
Tj\nET\nendstream\nendobj\nxref\n0 6\n0000000000 65535 f \n0000000009 00000
n \n0000000058 00000 n \n0000000115 00000 n \n0000000214 00000 n \n0000000293
00000 n \ntrailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n385\n%%EOF\n\r\n--ba392e7bb7694af286b0acfe46365a0f--\r\n"
headers:
Accept:
- '*/*'
Content-Type:
- multipart/form-data; boundary=ba392e7bb7694af286b0acfe46365a0f
User-Agent:
- litellm/1.101.0
method: POST
uri: http://parity-provider.invalid/upload
response:
body:
string: '{"file_id":"reducto://a6d3c3bb-a6f1-4636-8b3a-ee920b29d387.pdf","presigned_url":null}'
headers:
Content-Type:
- application/json
Date:
- Tue, 01 Sep 2026 19:54:46 GMT
status:
code: 200
message: ''
- request:
body: '{"input":"reducto://a6d3c3bb-a6f1-4636-8b3a-ee920b29d387.pdf"}'
headers:
Accept:
- '*/*'
Content-Type:
- application/json
User-Agent:
- litellm/1.101.0
method: POST
uri: http://parity-provider.invalid/parse
response:
body:
string: '{"response_type":"parse","job_id":"4a78c975-1865-46fe-8f89-bea92a49e215","duration":1.1194427013397217,"pdf_url":"https://prod-storage20241010144745140900000001.s3.amazonaws.com/org/8bd983d1-47dd-440b-93d8-431412d819bf/6ab9bcf4-1893-4b37-bddb-acda8ce45dfb.pdf?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIA2UOK6OVBOUYL7WYA%2F20260901%2Fus-west-2%2Fs3%2Faws4_request&X-Amz-Date=20260901T195445Z&X-Amz-Expires=43200&X-Amz-SignedHeaders=host&X-Amz-Signature=1db61d33f602f13e8444e8580b737b31f346e47493cc7eca7c4fcddbde0dfb43","studio_link":"https://studio.reducto.ai/job/d345549f-5d98-4c62-b3e3-24c990144df4","usage":{"num_pages":1,"credits":1.0,"credit_breakdown":{"page":1.0},"page_billing_breakdown":{"1":["page"]},"non_empty_cell_count":null},"result":{"type":"full","chunks":[{"content":"#
Test PDF File","embed":"# Test PDF File","enriched":null,"enrichment_success":false,"blocks":[{"type":"Title","bbox":{"left":0.16189574527182748,"top":0.09479295553814121,"width":0.11946290650820875,"height":0.02146414301710507,"page":1,"original_page":1},"content":"Test
PDF File","image_url":null,"chart_data":null,"confidence":"high","granular_confidence":{"extract_confidence":null,"parse_confidence":0.7185355693101882},"extra":null}]}],"ocr":null,"custom":null},"parse_mode":null,"document_properties":null}'
headers:
Content-Type:
- application/json
Date:
- Tue, 01 Sep 2026 19:54:47 GMT
status:
code: 200
message: ''
recorded_at: '2026-09-01T19:54:47.967951+00:00'
ttl_seconds: 0
version: 1
x-litellm:
case:
litellm_input:
contract: reducto_v3
custom_llm_provider: reducto
document:
document_url: data:application/pdf;base64,JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvUmVzb3VyY2VzIDQgMCBSIC9NZWRpYUJveCBbMCAwIDYxMiA3OTJdIC9Db250ZW50cyA1IDAgUiA+PgplbmRvYmoKNCAwIG9iago8PCAvRm9udCA8PCAvRjEgPDwgL1R5cGUgL0ZvbnQgL1N1YnR5cGUgL1R5cGUxIC9CYXNlRm9udCAvSGVsdmV0aWNhID4+ID4+ID4+CmVuZG9iago1IDAgb2JqCjw8IC9MZW5ndGggNDQgPj4Kc3RyZWFtCkJUCi9GMSAxMiBUZgoxMDAgNzAwIFRkCihUZXN0IFBERiBGaWxlKSBUagpFVAplbmRzdHJlYW0KZW5kb2JqCnhyZWYKMCA2CjAwMDAwMDAwMDAgNjU1MzUgZiAKMDAwMDAwMDAwOSAwMDAwMCBuIAowMDAwMDAwMDU4IDAwMDAwIG4gCjAwMDAwMDAxMTUgMDAwMDAgbiAKMDAwMDAwMDIxNCAwMDAwMCBuIAowMDAwMDAwMjkzIDAwMDAwIG4gCnRyYWlsZXIKPDwgL1NpemUgNiAvUm9vdCAxIDAgUiA+PgpzdGFydHhyZWYKMzg1CiUlRU9GCg==
type: document_url
model: parse-v3
request_source: python_replay
schema_version: 1

View file

@ -0,0 +1,70 @@
interactions:
- request:
body: "--0f6127f8c781afd158616115a5ebdece\r\nContent-Disposition: form-data; name=\"file\";
filename=\"document\"\r\nContent-Type: application/pdf\r\n\r\n%PDF-1.4\n1 0
obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n2 0 obj\n<< /Type /Pages /Kids
[3 0 R] /Count 1 >>\nendobj\n3 0 obj\n<< /Type /Page /Parent 2 0 R /Resources
4 0 R /MediaBox [0 0 612 792] /Contents 5 0 R >>\nendobj\n4 0 obj\n<< /Font
<< /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> >>\nendobj\n5
0 obj\n<< /Length 44 >>\nstream\nBT\n/F1 12 Tf\n100 700 Td\n(Test PDF File)
Tj\nET\nendstream\nendobj\nxref\n0 6\n0000000000 65535 f \n0000000009 00000
n \n0000000058 00000 n \n0000000115 00000 n \n0000000214 00000 n \n0000000293
00000 n \ntrailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n385\n%%EOF\n\r\n--0f6127f8c781afd158616115a5ebdece--\r\n"
headers:
Accept:
- '*/*'
Content-Type:
- multipart/form-data; boundary=0f6127f8c781afd158616115a5ebdece
User-Agent:
- litellm/1.101.0
method: POST
uri: http://parity-provider.invalid/upload
response:
body:
string: '{"file_id":"reducto://6ab9bcf4-1893-4b37-bddb-acda8ce45dfb.pdf","presigned_url":null}'
headers:
Content-Type:
- application/json
Date:
- Tue, 01 Sep 2026 19:54:44 GMT
status:
code: 200
message: ''
- request:
body: '{"input":"reducto://6ab9bcf4-1893-4b37-bddb-acda8ce45dfb.pdf"}'
headers:
Accept:
- '*/*'
Content-Type:
- application/json
User-Agent:
- litellm/1.101.0
method: POST
uri: http://parity-provider.invalid/parse
response:
body:
string: '{"response_type":"parse","job_id":"d345549f-5d98-4c62-b3e3-24c990144df4","duration":1.1194427013397217,"pdf_url":"https://prod-storage20241010144745140900000001.s3.amazonaws.com/org/8bd983d1-47dd-440b-93d8-431412d819bf/6ab9bcf4-1893-4b37-bddb-acda8ce45dfb.pdf?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIA2UOK6OVBOUYL7WYA%2F20260901%2Fus-west-2%2Fs3%2Faws4_request&X-Amz-Date=20260901T195445Z&X-Amz-Expires=43200&X-Amz-SignedHeaders=host&X-Amz-Signature=1db61d33f602f13e8444e8580b737b31f346e47493cc7eca7c4fcddbde0dfb43","studio_link":"https://studio.reducto.ai/job/d345549f-5d98-4c62-b3e3-24c990144df4","usage":{"num_pages":1,"credits":1.0,"credit_breakdown":{"page":1.0},"page_billing_breakdown":{"1":["page"]},"non_empty_cell_count":null},"result":{"type":"full","chunks":[{"content":"#
Test PDF File","embed":"# Test PDF File","enriched":null,"enrichment_success":false,"blocks":[{"type":"Title","bbox":{"left":0.16189574527182748,"top":0.09479295553814121,"width":0.11946290650820875,"height":0.02146414301710507,"page":1,"original_page":1},"content":"Test
PDF File","image_url":null,"chart_data":null,"confidence":"high","granular_confidence":{"extract_confidence":null,"parse_confidence":0.7185355693101882},"extra":null}]}],"ocr":null,"custom":null},"parse_mode":null,"document_properties":null}'
headers:
Content-Type:
- application/json
Date:
- Tue, 01 Sep 2026 19:54:46 GMT
status:
code: 200
message: ''
recorded_at: '2026-09-01T19:54:46.694836+00:00'
ttl_seconds: 0
version: 1
x-litellm:
case:
litellm_input:
contract: reducto_v3
custom_llm_provider: null
document:
document_url: data:application/pdf;base64,JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvUmVzb3VyY2VzIDQgMCBSIC9NZWRpYUJveCBbMCAwIDYxMiA3OTJdIC9Db250ZW50cyA1IDAgUiA+PgplbmRvYmoKNCAwIG9iago8PCAvRm9udCA8PCAvRjEgPDwgL1R5cGUgL0ZvbnQgL1N1YnR5cGUgL1R5cGUxIC9CYXNlRm9udCAvSGVsdmV0aWNhID4+ID4+ID4+CmVuZG9iago1IDAgb2JqCjw8IC9MZW5ndGggNDQgPj4Kc3RyZWFtCkJUCi9GMSAxMiBUZgoxMDAgNzAwIFRkCihUZXN0IFBERiBGaWxlKSBUagpFVAplbmRzdHJlYW0KZW5kb2JqCnhyZWYKMCA2CjAwMDAwMDAwMDAgNjU1MzUgZiAKMDAwMDAwMDAwOSAwMDAwMCBuIAowMDAwMDAwMDU4IDAwMDAwIG4gCjAwMDAwMDAxMTUgMDAwMDAgbiAKMDAwMDAwMDIxNCAwMDAwMCBuIAowMDAwMDAwMjkzIDAwMDAwIG4gCnRyYWlsZXIKPDwgL1NpemUgNiAvUm9vdCAxIDAgUiA+PgpzdGFydHhyZWYKMzg1CiUlRU9GCg==
type: document_url
model: reducto/parse-v3
request_source: python_replay
schema_version: 1

View file

@ -0,0 +1,100 @@
interactions:
- request:
body: "--22b7cb49f85a1ae5113a6328b0381ffb\r\nContent-Disposition: form-data; name=\"file\";
filename=\"document\"\r\nContent-Type: application/pdf\r\n\r\n%PDF-1.4\n1 0
obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n2 0 obj\n<< /Type /Pages /Kids
[3 0 R] /Count 1 >>\nendobj\n3 0 obj\n<< /Type /Page /Parent 2 0 R /Resources
4 0 R /MediaBox [0 0 612 792] /Contents 5 0 R >>\nendobj\n4 0 obj\n<< /Font
<< /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> >>\nendobj\n5
0 obj\n<< /Length 44 >>\nstream\nBT\n/F1 12 Tf\n100 700 Td\n(Test PDF File)
Tj\nET\nendstream\nendobj\nxref\n0 6\n0000000000 65535 f \n0000000009 00000
n \n0000000058 00000 n \n0000000115 00000 n \n0000000214 00000 n \n0000000293
00000 n \ntrailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n385\n%%EOF\n\r\n--22b7cb49f85a1ae5113a6328b0381ffb--\r\n"
headers:
Accept:
- '*/*'
Content-Type:
- multipart/form-data; boundary=22b7cb49f85a1ae5113a6328b0381ffb
User-Agent:
- litellm/1.101.0
method: POST
uri: http://parity-provider.invalid/upload
response:
body:
string: '{"file_id":"reducto://5df745f4-2877-4cf6-9bde-a2f829c93eea.pdf","presigned_url":null}'
headers:
Content-Type:
- application/json
Date:
- Tue, 01 Sep 2026 19:54:48 GMT
status:
code: 200
message: ''
- request:
body: '{"input":"reducto://5df745f4-2877-4cf6-9bde-a2f829c93eea.pdf","formatting":{"add_page_markers":false,"table_output_format":"json","merge_tables":true,"include":["signatures","ignore_watermarks"]},"retrieval":{"chunking":{"chunk_mode":"variable","chunk_size":1500,"chunk_overlap":32},"filter_blocks":["Figure","Table","Key
Value"],"embedding_optimized":false},"settings":{"ocr_system":"legacy","extraction_mode":"hybrid","force_url_result":false,"return_ocr_data":false,"return_images":[],"embed_pdf_metadata":false,"embed_pdf_metadata_dpi":100,"persist_results":false,"timeout":900.0,"page_range":[1]}}'
headers:
Accept:
- '*/*'
Content-Type:
- application/json
User-Agent:
- litellm/1.101.0
method: POST
uri: http://parity-provider.invalid/parse
response:
body:
string: '{"response_type":"parse","job_id":"7ca67242-1677-484e-b2ef-b256dcdd1ea4","duration":1.3359308242797852,"pdf_url":"https://prod-storage20241010144745140900000001.s3.amazonaws.com/org/8bd983d1-47dd-440b-93d8-431412d819bf/5df745f4-2877-4cf6-9bde-a2f829c93eea.pdf?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIA2UOK6OVBOUYL7WYA%2F20260901%2Fus-west-2%2Fs3%2Faws4_request&X-Amz-Date=20260901T195449Z&X-Amz-Expires=43200&X-Amz-SignedHeaders=host&X-Amz-Signature=077a6551201d2562fd1543fd5c391541cffe606d64307a3f6d103cf18219f80a","studio_link":"https://studio.reducto.ai/job/7ca67242-1677-484e-b2ef-b256dcdd1ea4","usage":{"num_pages":1,"credits":1.0,"credit_breakdown":{"page":1.0},"page_billing_breakdown":{"1":["page"]},"non_empty_cell_count":null},"result":{"type":"full","chunks":[{"content":"#
Test PDF File","embed":"# Test PDF File","enriched":null,"enrichment_success":false,"blocks":[{"type":"Title","bbox":{"left":0.16189574527182748,"top":0.09479295553814121,"width":0.11946290650820875,"height":0.02146414301710507,"page":1,"original_page":1},"content":"Test
PDF File","image_url":null,"chart_data":null,"confidence":"high","granular_confidence":{"extract_confidence":null,"parse_confidence":0.7185355693101882},"extra":null}]}],"ocr":null,"custom":null},"parse_mode":null,"document_properties":null}'
headers:
Content-Type:
- application/json
Date:
- Tue, 01 Sep 2026 19:54:50 GMT
status:
code: 200
message: ''
recorded_at: '2026-09-01T19:54:50.708637+00:00'
ttl_seconds: 0
version: 1
x-litellm:
case:
litellm_input:
contract: reducto_v3
custom_llm_provider: null
document:
document_url: data:application/pdf;base64,JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvUmVzb3VyY2VzIDQgMCBSIC9NZWRpYUJveCBbMCAwIDYxMiA3OTJdIC9Db250ZW50cyA1IDAgUiA+PgplbmRvYmoKNCAwIG9iago8PCAvRm9udCA8PCAvRjEgPDwgL1R5cGUgL0ZvbnQgL1N1YnR5cGUgL1R5cGUxIC9CYXNlRm9udCAvSGVsdmV0aWNhID4+ID4+ID4+CmVuZG9iago1IDAgb2JqCjw8IC9MZW5ndGggNDQgPj4Kc3RyZWFtCkJUCi9GMSAxMiBUZgoxMDAgNzAwIFRkCihUZXN0IFBERiBGaWxlKSBUagpFVAplbmRzdHJlYW0KZW5kb2JqCnhyZWYKMCA2CjAwMDAwMDAwMDAgNjU1MzUgZiAKMDAwMDAwMDAwOSAwMDAwMCBuIAowMDAwMDAwMDU4IDAwMDAwIG4gCjAwMDAwMDAxMTUgMDAwMDAgbiAKMDAwMDAwMDIxNCAwMDAwMCBuIAowMDAwMDAwMjkzIDAwMDAwIG4gCnRyYWlsZXIKPDwgL1NpemUgNiAvUm9vdCAxIDAgUiA+PgpzdGFydHhyZWYKMzg1CiUlRU9GCg==
type: document_url
formatting:
add_page_markers: false
include:
- signatures
- ignore_watermarks
merge_tables: true
table_output_format: json
model: reducto/parse-v3
retrieval:
chunking:
chunk_mode: variable
chunk_overlap: 32
chunk_size: 1500
embedding_optimized: false
filter_blocks:
- Figure
- Table
- Key Value
settings:
embed_pdf_metadata: false
embed_pdf_metadata_dpi: 100
extraction_mode: hybrid
force_url_result: false
ocr_system: legacy
page_range:
- 1
persist_results: false
return_images: []
return_ocr_data: false
timeout: 900.0
request_source: python_replay
schema_version: 1

View file

@ -0,0 +1,72 @@
interactions:
- request:
body: "--7108360c769817ee2464c4729615e289\r\nContent-Disposition: form-data; name=\"file\";
filename=\"document\"\r\nContent-Type: application/pdf\r\n\r\n%PDF-1.4\n1 0
obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n2 0 obj\n<< /Type /Pages /Kids
[3 0 R] /Count 1 >>\nendobj\n3 0 obj\n<< /Type /Page /Parent 2 0 R /Resources
4 0 R /MediaBox [0 0 612 792] /Contents 5 0 R >>\nendobj\n4 0 obj\n<< /Font
<< /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> >>\nendobj\n5
0 obj\n<< /Length 44 >>\nstream\nBT\n/F1 12 Tf\n100 700 Td\n(Test PDF File)
Tj\nET\nendstream\nendobj\nxref\n0 6\n0000000000 65535 f \n0000000009 00000
n \n0000000058 00000 n \n0000000115 00000 n \n0000000214 00000 n \n0000000293
00000 n \ntrailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n385\n%%EOF\n\r\n--7108360c769817ee2464c4729615e289--\r\n"
headers:
Accept:
- '*/*'
Content-Type:
- multipart/form-data; boundary=7108360c769817ee2464c4729615e289
User-Agent:
- litellm/1.101.0
method: POST
uri: http://parity-provider.invalid/upload
response:
body:
string: '{"file_id":"reducto://fc6ebcb1-95d4-46ec-90d0-efca33ef197f.pdf","presigned_url":null}'
headers:
Content-Type:
- application/json
Date:
- Tue, 01 Sep 2026 19:54:41 GMT
status:
code: 200
message: ''
- request:
body: '{"input":"reducto://fc6ebcb1-95d4-46ec-90d0-efca33ef197f.pdf","settings":{"return_ocr_data":true}}'
headers:
Accept:
- '*/*'
Content-Type:
- application/json
User-Agent:
- litellm/1.101.0
method: POST
uri: http://parity-provider.invalid/parse
response:
body:
string: '{"response_type":"parse","job_id":"3f6ef64d-c949-4b8d-9898-afb3b01669e6","duration":1.4480743408203125,"pdf_url":"https://prod-storage20241010144745140900000001.s3.amazonaws.com/org/8bd983d1-47dd-440b-93d8-431412d819bf/fc6ebcb1-95d4-46ec-90d0-efca33ef197f.pdf?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIA2UOK6OVBOUYL7WYA%2F20260901%2Fus-west-2%2Fs3%2Faws4_request&X-Amz-Date=20260901T195443Z&X-Amz-Expires=43200&X-Amz-SignedHeaders=host&X-Amz-Signature=0965ca129cee49a41650df152057a17a4f9e265f92f2261a4f6576587272eb03","studio_link":"https://studio.reducto.ai/job/3f6ef64d-c949-4b8d-9898-afb3b01669e6","usage":{"num_pages":1,"credits":1.0,"credit_breakdown":{"page":1.0},"page_billing_breakdown":{"1":["page"]},"non_empty_cell_count":null},"result":{"type":"full","chunks":[{"content":"#
Test PDF File","embed":"# Test PDF File","enriched":null,"enrichment_success":false,"blocks":[{"type":"Title","bbox":{"left":0.16189574527182748,"top":0.09479295553814121,"width":0.11946290650820875,"height":0.02146414301710507,"page":1,"original_page":1},"content":"Test
PDF File","image_url":null,"chart_data":null,"confidence":"high","granular_confidence":{"extract_confidence":null,"parse_confidence":0.7185355693101882},"extra":null}]}],"ocr":{"words":[{"text":"Test","bbox":{"left":0.16189475464665032,"top":0.09491921434498797,"width":0.03832089043910207,"height":0.021019531018806225,"page":1,"original_page":1},"confidence":1.0,"chunk_index":null,"rotation":359},{"text":"PDF","bbox":{"left":0.20548195932425706,"top":0.09514990719881924,"width":0.03939929039649714,"height":0.02102524343163076,"page":1,"original_page":1},"confidence":1.0,"chunk_index":null,"rotation":359},{"text":"File","bbox":{"left":0.25014757642558977,"top":0.09538630283240115,"width":0.03177201514150582,"height":0.020984871218902895,"page":1,"original_page":1},"confidence":1.0,"chunk_index":null,"rotation":359}],"lines":[{"text":"Test
PDF File","bbox":{"left":0.16189475464665032,"top":0.09491921434498797,"width":0.12002483692044526,"height":0.021451959706316092,"page":1,"original_page":1},"confidence":1.0,"chunk_index":null,"rotation":359}]},"custom":null},"parse_mode":null,"document_properties":null}'
headers:
Content-Type:
- application/json
Date:
- Tue, 01 Sep 2026 19:54:43 GMT
status:
code: 200
message: ''
recorded_at: '2026-09-01T19:54:43.968997+00:00'
ttl_seconds: 0
version: 1
x-litellm:
case:
litellm_input:
contract: reducto_v3
document:
document_url: data:application/pdf;base64,JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvUmVzb3VyY2VzIDQgMCBSIC9NZWRpYUJveCBbMCAwIDYxMiA3OTJdIC9Db250ZW50cyA1IDAgUiA+PgplbmRvYmoKNCAwIG9iago8PCAvRm9udCA8PCAvRjEgPDwgL1R5cGUgL0ZvbnQgL1N1YnR5cGUgL1R5cGUxIC9CYXNlRm9udCAvSGVsdmV0aWNhID4+ID4+ID4+CmVuZG9iago1IDAgb2JqCjw8IC9MZW5ndGggNDQgPj4Kc3RyZWFtCkJUCi9GMSAxMiBUZgoxMDAgNzAwIFRkCihUZXN0IFBERiBGaWxlKSBUagpFVAplbmRzdHJlYW0KZW5kb2JqCnhyZWYKMCA2CjAwMDAwMDAwMDAgNjU1MzUgZiAKMDAwMDAwMDAwOSAwMDAwMCBuIAowMDAwMDAwMDU4IDAwMDAwIG4gCjAwMDAwMDAxMTUgMDAwMDAgbiAKMDAwMDAwMDIxNCAwMDAwMCBuIAowMDAwMDAwMjkzIDAwMDAwIG4gCnRyYWlsZXIKPDwgL1NpemUgNiAvUm9vdCAxIDAgUiA+PgpzdGFydHhyZWYKMzg1CiUlRU9GCg==
type: document_url
model: reducto/parse-v3
settings:
return_ocr_data: true
request_source: python_replay
schema_version: 1

View file

@ -0,0 +1,39 @@
interactions:
- request:
body: '{"input":"reducto://invalid-document-for-parity"}'
headers:
Accept:
- '*/*'
Content-Type:
- application/json
User-Agent:
- litellm/1.101.0
method: POST
uri: http://parity-provider.invalid/parse
response:
body:
string: '{"error":{"code":404,"name":"NOT_FOUND","message":"Document ''The file
may have expired or been deleted. Please re-upload and try again.'' not found"},"detail":"Document
''The file may have expired or been deleted. Please re-upload and try again.''
not found"}'
headers:
Content-Type:
- application/json
Date:
- Wed, 02 Sep 2026 01:14:02 GMT
status:
code: 404
message: ''
recorded_at: '2026-09-02T01:14:02.538070+00:00'
ttl_seconds: 0
version: 1
x-litellm:
case:
litellm_input:
contract: reducto_v3
document:
document_url: reducto://invalid-document-for-parity
type: document_url
model: reducto/parse-v3
request_source: python_replay
schema_version: 1

View file

@ -0,0 +1,71 @@
interactions:
- request:
body: "--04abc28545d4b46a8ee703e56d88cc0c\r\nContent-Disposition: form-data; name=\"file\";
filename=\"document\"\r\nContent-Type: application/pdf\r\n\r\n%PDF-1.4\n1 0
obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n2 0 obj\n<< /Type /Pages /Kids
[3 0 R] /Count 1 >>\nendobj\n3 0 obj\n<< /Type /Page /Parent 2 0 R /Resources
4 0 R /MediaBox [0 0 612 792] /Contents 5 0 R >>\nendobj\n4 0 obj\n<< /Font
<< /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> >>\nendobj\n5
0 obj\n<< /Length 44 >>\nstream\nBT\n/F1 12 Tf\n100 700 Td\n(Test PDF File)
Tj\nET\nendstream\nendobj\nxref\n0 6\n0000000000 65535 f \n0000000009 00000
n \n0000000058 00000 n \n0000000115 00000 n \n0000000214 00000 n \n0000000293
00000 n \ntrailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n385\n%%EOF\n\r\n--04abc28545d4b46a8ee703e56d88cc0c--\r\n"
headers:
Accept:
- '*/*'
Content-Type:
- multipart/form-data; boundary=04abc28545d4b46a8ee703e56d88cc0c
User-Agent:
- litellm/1.101.0
method: POST
uri: http://parity-provider.invalid/upload
response:
body:
string: '{"file_id":"reducto://f3cc3c72-614a-4104-bb70-a516f37148e0.pdf","presigned_url":null}'
headers:
Content-Type:
- application/json
Date:
- Tue, 01 Sep 2026 19:54:30 GMT
status:
code: 200
message: ''
- request:
body: '{"input":"reducto://f3cc3c72-614a-4104-bb70-a516f37148e0.pdf","formatting":{"table_output_format":"md"}}'
headers:
Accept:
- '*/*'
Content-Type:
- application/json
User-Agent:
- litellm/1.101.0
method: POST
uri: http://parity-provider.invalid/parse
response:
body:
string: '{"response_type":"parse","job_id":"aba924dc-7773-4dff-a4a8-3549366ea9fa","duration":4.5764172077178955,"pdf_url":"https://prod-storage20241010144745140900000001.s3.amazonaws.com/org/8bd983d1-47dd-440b-93d8-431412d819bf/f3cc3c72-614a-4104-bb70-a516f37148e0.pdf?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIA2UOK6OVBOUYL7WYA%2F20260901%2Fus-west-2%2Fs3%2Faws4_request&X-Amz-Date=20260901T195435Z&X-Amz-Expires=43200&X-Amz-SignedHeaders=host&X-Amz-Signature=7d4c9464bb3a1a9d5c7a5f957bc10813fb16fcc6de3fea2054a40b469ffa79b3","studio_link":"https://studio.reducto.ai/job/aba924dc-7773-4dff-a4a8-3549366ea9fa","usage":{"num_pages":1,"credits":1.0,"credit_breakdown":{"page":1.0},"page_billing_breakdown":{"1":["page"]},"non_empty_cell_count":null},"result":{"type":"full","chunks":[{"content":"#
Test PDF File","embed":"# Test PDF File","enriched":null,"enrichment_success":false,"blocks":[{"type":"Title","bbox":{"left":0.16189574527182748,"top":0.09479295553814121,"width":0.11946290650820875,"height":0.02146414301710507,"page":1,"original_page":1},"content":"Test
PDF File","image_url":null,"chart_data":null,"confidence":"high","granular_confidence":{"extract_confidence":null,"parse_confidence":0.7185355693101882},"extra":null}]}],"ocr":null,"custom":null},"parse_mode":null,"document_properties":null}'
headers:
Content-Type:
- application/json
Date:
- Tue, 01 Sep 2026 19:54:35 GMT
status:
code: 200
message: ''
recorded_at: '2026-09-01T19:54:35.993066+00:00'
ttl_seconds: 0
version: 1
x-litellm:
case:
litellm_input:
contract: reducto_v3
document:
document_url: data:application/pdf;base64,JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvUmVzb3VyY2VzIDQgMCBSIC9NZWRpYUJveCBbMCAwIDYxMiA3OTJdIC9Db250ZW50cyA1IDAgUiA+PgplbmRvYmoKNCAwIG9iago8PCAvRm9udCA8PCAvRjEgPDwgL1R5cGUgL0ZvbnQgL1N1YnR5cGUgL1R5cGUxIC9CYXNlRm9udCAvSGVsdmV0aWNhID4+ID4+ID4+CmVuZG9iago1IDAgb2JqCjw8IC9MZW5ndGggNDQgPj4Kc3RyZWFtCkJUCi9GMSAxMiBUZgoxMDAgNzAwIFRkCihUZXN0IFBERiBGaWxlKSBUagpFVAplbmRzdHJlYW0KZW5kb2JqCnhyZWYKMCA2CjAwMDAwMDAwMDAgNjU1MzUgZiAKMDAwMDAwMDAwOSAwMDAwMCBuIAowMDAwMDAwMDU4IDAwMDAwIG4gCjAwMDAwMDAxMTUgMDAwMDAgbiAKMDAwMDAwMDIxNCAwMDAwMCBuIAowMDAwMDAwMjkzIDAwMDAwIG4gCnRyYWlsZXIKPDwgL1NpemUgNiAvUm9vdCAxIDAgUiA+PgpzdGFydHhyZWYKMzg1CiUlRU9GCg==
type: document_url
formatting:
table_output_format: md
model: reducto/parse-v3
request_source: python_replay
schema_version: 1

View file

@ -0,0 +1,69 @@
interactions:
- request:
body: "--b7daa0c5be8f80d33a4eb7a54318d93a\r\nContent-Disposition: form-data; name=\"file\";
filename=\"document\"\r\nContent-Type: application/pdf\r\n\r\n%PDF-1.4\n1 0
obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n2 0 obj\n<< /Type /Pages /Kids
[3 0 R] /Count 1 >>\nendobj\n3 0 obj\n<< /Type /Page /Parent 2 0 R /Resources
4 0 R /MediaBox [0 0 612 792] /Contents 5 0 R >>\nendobj\n4 0 obj\n<< /Font
<< /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> >>\nendobj\n5
0 obj\n<< /Length 44 >>\nstream\nBT\n/F1 12 Tf\n100 700 Td\n(Test PDF File)
Tj\nET\nendstream\nendobj\nxref\n0 6\n0000000000 65535 f \n0000000009 00000
n \n0000000058 00000 n \n0000000115 00000 n \n0000000214 00000 n \n0000000293
00000 n \ntrailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n385\n%%EOF\n\r\n--b7daa0c5be8f80d33a4eb7a54318d93a--\r\n"
headers:
Accept:
- '*/*'
Content-Type:
- multipart/form-data; boundary=b7daa0c5be8f80d33a4eb7a54318d93a
User-Agent:
- litellm/1.101.0
method: POST
uri: http://parity-provider.invalid/upload
response:
body:
string: '{"file_id":"reducto://010b01b2-83bd-446d-af11-d120c5ac2c02.pdf","presigned_url":null}'
headers:
Content-Type:
- application/json
Date:
- Tue, 01 Sep 2026 19:54:27 GMT
status:
code: 200
message: ''
- request:
body: '{"input":"reducto://010b01b2-83bd-446d-af11-d120c5ac2c02.pdf"}'
headers:
Accept:
- '*/*'
Content-Type:
- application/json
User-Agent:
- litellm/1.101.0
method: POST
uri: http://parity-provider.invalid/parse
response:
body:
string: '{"response_type":"parse","job_id":"8138a6c6-b726-4300-90b4-e6d5d43f6f70","duration":1.2648272514343262,"pdf_url":"https://prod-storage20241010144745140900000001.s3.amazonaws.com/org/8bd983d1-47dd-440b-93d8-431412d819bf/010b01b2-83bd-446d-af11-d120c5ac2c02.pdf?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIA2UOK6OVBOUYL7WYA%2F20260901%2Fus-west-2%2Fs3%2Faws4_request&X-Amz-Date=20260901T195428Z&X-Amz-Expires=43200&X-Amz-SignedHeaders=host&X-Amz-Signature=3cda9020fefff85d9dd13f124908d7b00e55b6e82a72039546d262099cfcf3b4","studio_link":"https://studio.reducto.ai/job/8138a6c6-b726-4300-90b4-e6d5d43f6f70","usage":{"num_pages":1,"credits":1.0,"credit_breakdown":{"page":1.0},"page_billing_breakdown":{"1":["page"]},"non_empty_cell_count":null},"result":{"type":"full","chunks":[{"content":"#
Test PDF File","embed":"# Test PDF File","enriched":null,"enrichment_success":false,"blocks":[{"type":"Title","bbox":{"left":0.16189574527182748,"top":0.09479295553814121,"width":0.11946290650820875,"height":0.02146414301710507,"page":1,"original_page":1},"content":"Test
PDF File","image_url":null,"chart_data":null,"confidence":"high","granular_confidence":{"extract_confidence":null,"parse_confidence":0.7185355693101882},"extra":null}]}],"ocr":null,"custom":null},"parse_mode":null,"document_properties":null}'
headers:
Content-Type:
- application/json
Date:
- Tue, 01 Sep 2026 19:54:29 GMT
status:
code: 200
message: ''
recorded_at: '2026-09-01T19:54:30.255600+00:00'
ttl_seconds: 0
version: 1
x-litellm:
case:
litellm_input:
contract: reducto_v3
document:
document_url: data:application/pdf;base64,JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvUmVzb3VyY2VzIDQgMCBSIC9NZWRpYUJveCBbMCAwIDYxMiA3OTJdIC9Db250ZW50cyA1IDAgUiA+PgplbmRvYmoKNCAwIG9iago8PCAvRm9udCA8PCAvRjEgPDwgL1R5cGUgL0ZvbnQgL1N1YnR5cGUgL1R5cGUxIC9CYXNlRm9udCAvSGVsdmV0aWNhID4+ID4+ID4+CmVuZG9iago1IDAgb2JqCjw8IC9MZW5ndGggNDQgPj4Kc3RyZWFtCkJUCi9GMSAxMiBUZgoxMDAgNzAwIFRkCihUZXN0IFBERiBGaWxlKSBUagpFVAplbmRzdHJlYW0KZW5kb2JqCnhyZWYKMCA2CjAwMDAwMDAwMDAgNjU1MzUgZiAKMDAwMDAwMDAwOSAwMDAwMCBuIAowMDAwMDAwMDU4IDAwMDAwIG4gCjAwMDAwMDAxMTUgMDAwMDAgbiAKMDAwMDAwMDIxNCAwMDAwMCBuIAowMDAwMDAwMjkzIDAwMDAwIG4gCnRyYWlsZXIKPDwgL1NpemUgNiAvUm9vdCAxIDAgUiA+PgpzdGFydHhyZWYKMzg1CiUlRU9GCg==
type: document_url
model: reducto/parse-v3
request_source: python_replay
schema_version: 1

View file

@ -0,0 +1,63 @@
from __future__ import annotations
import argparse
from pathlib import Path
from typing import Final, cast
import litellm
from litellm.rust_bridge.ocr import use_litellm_rust
from tests.route_parity.fixtures.recording import (
RecordedInteraction,
UpstreamEndpoint,
record_upstream_interactions,
)
from tests.route_parity.fixtures.store import FixtureEnvelope, read_fixture, save_fixture
from tests.route_parity.replay import replay_server
from tests.test_litellm.ocr.fixtures.common import OcrSdkCall
from tests.test_litellm.ocr.fixtures.config import configured_fixture_directory
from tests.test_litellm.ocr.fixtures.models import OcrParityCase, OcrSdkInput
def _invoke(provider_url: str, case_input: OcrSdkInput) -> object:
sdk_call: Final = cast(OcrSdkCall, litellm.ocr)
return sdk_call(api_base=provider_url, api_key="test-key", **case_input.as_sdk_kwargs())
def migrate_fixture(path: Path) -> Path:
case: Final = read_fixture(path, OcrParityCase)
envelope: Final = FixtureEnvelope.model_validate_json(path.read_text(encoding="utf-8"))
with replay_server() as provider:
for response in case.provider_responses:
provider.enqueue_response(response)
captured: Final = record_upstream_interactions(UpstreamEndpoint(provider.url), case.litellm_input, _invoke)
provider.take_requests(len(case.provider_responses))
interactions: Final = tuple(
RecordedInteraction(item.request, response)
for item, response in zip(captured, case.provider_responses, strict=True)
)
destination: Final = save_fixture(
path.parent,
case.litellm_input,
case,
interactions,
recorded_at=envelope.recorded_at,
request_source="python_replay",
)
read_fixture(destination, OcrParityCase)
path.unlink()
return destination
def main() -> None:
parser: Final = argparse.ArgumentParser()
parser.add_argument("--fixture-dir", type=Path, default=configured_fixture_directory())
args: Final = parser.parse_args()
directory: Final = cast(Path, args.fixture_dir)
use_litellm_rust(False, ocr=None, aocr=None)
paths: Final = tuple(sorted(directory.rglob("*.json")))
for path in paths:
print(f"Migrated {path.name} to {migrate_fixture(path).name}")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,259 @@
from __future__ import annotations
from collections.abc import Mapping
from typing import Final, Literal, cast
from hypothesis import strategies as st
from hypothesis.strategies import SearchStrategy
from pydantic import model_validator
from typing_extensions import Self
from tests.route_parity.fixtures.recording import UpstreamEndpoint
from tests.test_litellm.ocr.fixtures.base import (
JsonSchemaResponseFormat,
OcrDocument,
OcrSdkInputBase,
)
from tests.test_litellm.ocr.fixtures.common import (
OcrFixtureClient,
OcrRecordingTarget,
annotation_format,
document_transport_strategy,
invoke_with_api_key,
pdf_document,
)
MistralModel = Literal[
"mistral/mistral-ocr-3",
"mistral/mistral-ocr-3-0",
"mistral/mistral-ocr-2512",
"mistral/mistral-ocr-4-0",
"mistral/mistral-ocr-4-1",
"mistral/mistral-ocr-4",
"mistral/mistral-ocr-latest",
"mistral-ocr-3",
"mistral-ocr-3-0",
"mistral-ocr-2512",
"mistral-ocr-4-0",
"mistral-ocr-4-1",
"mistral-ocr-4",
"mistral-ocr-latest",
]
MistralFixtureModel = MistralModel | Literal["mistral/invalid-ocr-model-for-parity"]
MISTRAL_MODELS: Final[tuple[MistralModel, ...]] = (
"mistral/mistral-ocr-3",
"mistral/mistral-ocr-3-0",
"mistral/mistral-ocr-2512",
"mistral/mistral-ocr-4",
"mistral/mistral-ocr-4-0",
"mistral/mistral-ocr-4-1",
"mistral/mistral-ocr-latest",
)
class MistralCompatibleOcrSdkInput(OcrSdkInputBase):
document: OcrDocument
pages: str | list[int] | None = None
include_image_base64: bool | None = None
image_limit: int | None = None
image_min_size: int | None = None
bbox_annotation_format: JsonSchemaResponseFormat | None = None
document_annotation_format: JsonSchemaResponseFormat | None = None
document_annotation_prompt: str | None = None
extract_header: bool = False
extract_footer: bool = False
table_format: Literal["markdown", "html"] | None = None
confidence_scores_granularity: Literal["page", "word", "block"] | None = None
include_blocks: bool = True
id: str | None = None
@model_validator(mode="after")
def validate_annotation_prompt(self) -> Self:
if self.document_annotation_prompt is not None and self.document_annotation_format is None:
raise ValueError("document_annotation_prompt requires document_annotation_format")
return self
class MistralOcrSdkInput(MistralCompatibleOcrSdkInput):
contract: Literal["mistral"] = "mistral"
model: MistralFixtureModel
custom_llm_provider: Literal["mistral"] | None = None
@model_validator(mode="after")
def validate_provider_routing(self) -> Self:
if not self.model.startswith("mistral/") and self.custom_llm_provider != "mistral":
raise ValueError("unqualified Mistral models require custom_llm_provider='mistral'")
return self
MISTRAL_MODEL: Final[MistralModel] = "mistral/mistral-ocr-latest"
MISTRAL_PROVIDER_REJECTED_INPUTS: Final[tuple[MistralOcrSdkInput, ...]] = (
MistralOcrSdkInput(
model="mistral/invalid-ocr-model-for-parity",
document=pdf_document(),
),
)
MistralFeatureLevel = Literal["2505", "2512", "4"]
_MISTRAL_4_MODELS: Final = frozenset(
{
"mistral/mistral-ocr-4",
"mistral/mistral-ocr-4-0",
"mistral/mistral-ocr-4-1",
"mistral/mistral-ocr-latest",
}
)
_MISTRAL_2512_MODELS: Final = frozenset(
{*_MISTRAL_4_MODELS, "mistral/mistral-ocr-2512", "mistral/mistral-ocr-3", "mistral/mistral-ocr-3-0"}
)
def _feature_level(model: str) -> MistralFeatureLevel:
if model in _MISTRAL_4_MODELS:
return "4"
if model in _MISTRAL_2512_MODELS:
return "2512"
return "2505"
def _optional_param_strategies(
*,
include_document_annotation_prompt: bool = True,
) -> tuple[
tuple[SearchStrategy[dict[str, object]], ...],
tuple[SearchStrategy[dict[str, object]], ...],
tuple[SearchStrategy[dict[str, object]], ...],
]:
annotation: Final = annotation_format("document_title")
common: Final[tuple[SearchStrategy[dict[str, object]], ...]] = (
st.sampled_from(((0,), (0, 1))).map(list).map(lambda value: {"pages": value}),
st.sampled_from((False, True)).map(lambda value: {"include_image_base64": value}),
st.just({"image_limit": 1}),
st.just({"image_min_size": 300}),
st.just({"bbox_annotation_format": annotation_format("bounding_boxes")}),
st.just({"document_annotation_format": annotation}),
*(
(
st.just(
{
"document_annotation_format": annotation,
"document_annotation_prompt": "Extract the visible title",
}
),
)
if include_document_annotation_prompt
else ()
),
st.sampled_from(("page", "word")).map(lambda value: {"confidence_scores_granularity": value}),
)
feature_2512: Final[tuple[SearchStrategy[dict[str, object]], ...]] = (
st.sampled_from((False, True)).map(lambda value: {"extract_header": value}),
st.sampled_from((False, True)).map(lambda value: {"extract_footer": value}),
st.sampled_from(("markdown", "html")).map(lambda value: {"table_format": value}),
)
feature_4: Final[tuple[SearchStrategy[dict[str, object]], ...]] = (
st.just({"pages": "0-2"}),
st.sampled_from((False, True)).map(lambda value: {"include_blocks": value}),
st.just({"include_blocks": True, "confidence_scores_granularity": "block"}),
)
return common, feature_2512, feature_4
def mistral_optional_params_strategy(
feature_level: MistralFeatureLevel,
*,
include_document_annotation_prompt: bool = True,
) -> SearchStrategy[dict[str, object]]:
common, feature_2512, feature_4 = _optional_param_strategies(
include_document_annotation_prompt=include_document_annotation_prompt
)
return st.one_of(
*common,
*(feature_2512 if feature_level in {"2512", "4"} else ()),
*(feature_4 if feature_level == "4" else ()),
)
def _mistral_input_values(
document: OcrDocument,
optional_params: dict[str, object] | None = None,
) -> dict[str, object]:
return {"document": document, **(optional_params or {})}
def _mistral_input(
model: str,
document: OcrDocument,
optional_params: dict[str, object] | None = None,
) -> MistralOcrSdkInput:
return MistralOcrSdkInput.model_validate({"model": model, **_mistral_input_values(document, optional_params)})
def mistral_input_values_strategy(
feature_level: MistralFeatureLevel,
inline_image_data_uri: str,
*,
include_document_annotation_prompt: bool = True,
) -> SearchStrategy[dict[str, object]]:
option_document: Final = pdf_document()
return st.one_of(
document_transport_strategy(inline_image_data_uri).map(_mistral_input_values),
mistral_optional_params_strategy(
feature_level,
include_document_annotation_prompt=include_document_annotation_prompt,
).map(lambda optional_params: _mistral_input_values(option_document, optional_params)),
)
def mistral_input_strategy(
model: str,
inline_image_data_uri: str,
feature_level: MistralFeatureLevel | None = None,
) -> SearchStrategy[MistralOcrSdkInput]:
return mistral_input_values_strategy(feature_level or _feature_level(model), inline_image_data_uri).map(
lambda values: MistralOcrSdkInput.model_validate({"model": model, **values})
)
def _mistral_recording_strategy(inline_image_data_uri: str) -> SearchStrategy[MistralOcrSdkInput]:
document: Final = pdf_document()
baseline_models: Final = tuple(model for model in MISTRAL_MODELS if model != MISTRAL_MODEL)
common, feature_2512, feature_4 = _optional_param_strategies()
common_options: Final[SearchStrategy[dict[str, object]]] = st.one_of(*common)
feature_2512_options: Final[SearchStrategy[dict[str, object]]] = st.one_of(*feature_2512)
feature_4_options: Final[SearchStrategy[dict[str, object]]] = st.one_of(*feature_4)
return st.one_of(
st.sampled_from(baseline_models).map(lambda model: _mistral_input(model, document)),
document_transport_strategy(inline_image_data_uri).map(
lambda selected_document: _mistral_input(MISTRAL_MODEL, selected_document)
),
common_options.map(lambda optional_params: _mistral_input(MISTRAL_MODEL, document, optional_params)),
feature_2512_options.map(
lambda optional_params: _mistral_input("mistral/mistral-ocr-2512", document, optional_params)
),
feature_4_options.map(
lambda optional_params: _mistral_input("mistral/mistral-ocr-4-1", document, optional_params)
),
)
def mistral_recording_targets(
environ: Mapping[str, str], client: OcrFixtureClient, inline_image_data_uri: str
) -> tuple[OcrRecordingTarget, ...]:
api_key: Final = environ.get("MISTRAL_API_KEY")
if not api_key:
return ()
configured: Final = environ.get("MISTRAL_API_BASE", "https://api.mistral.ai").rstrip("/")
base_url: Final = configured.removesuffix("/v1")
return (
OcrRecordingTarget(
name="mistral-ocr",
upstream=UpstreamEndpoint(base_url=base_url),
strategy=cast(
SearchStrategy[OcrSdkInputBase],
_mistral_recording_strategy(inline_image_data_uri),
),
invocation=invoke_with_api_key(client, api_key),
required_inputs=MISTRAL_PROVIDER_REJECTED_INPUTS,
),
)

View file

@ -0,0 +1,71 @@
from __future__ import annotations
from collections.abc import Mapping
from typing import Annotated, Final, cast
from pydantic import Field, model_validator
from tests.route_parity.fixture_models import ParityCase
from tests.test_litellm.ocr.fixtures.azure import (
AzureDocumentIntelligenceOcrSdkInput,
AzureMistralOcrSdkInput,
)
from tests.test_litellm.ocr.fixtures.mistral import MistralOcrSdkInput
from tests.test_litellm.ocr.fixtures.reducto import ReductoParseLegacySdkInput, ReductoParseV3SdkInput
from tests.test_litellm.ocr.fixtures.vertex import VertexDeepSeekOcrSdkInput, VertexMistralOcrSdkInput
__all__ = ("OcrParityCase", "OcrSdkInput")
OcrSdkInput = Annotated[
MistralOcrSdkInput
| AzureMistralOcrSdkInput
| VertexMistralOcrSdkInput
| AzureDocumentIntelligenceOcrSdkInput
| VertexDeepSeekOcrSdkInput
| ReductoParseV3SdkInput
| ReductoParseLegacySdkInput,
Field(discriminator="contract"),
]
class OcrParityCase(ParityCase[OcrSdkInput]):
@model_validator(mode="before")
@classmethod
def load_legacy_contract(cls, value: object) -> object:
if not isinstance(value, Mapping):
return value
fixture: Final = cast(Mapping[str, object], value)
litellm_input: Final = fixture.get("litellm_input")
if not isinstance(litellm_input, Mapping) or "contract" in litellm_input:
return fixture
legacy_input: Final = cast(Mapping[str, object], litellm_input)
legacy_contract: Final = legacy_input.get("boundary")
if isinstance(legacy_contract, str):
return {
**fixture,
"litellm_input": {
"contract": legacy_contract,
**{key: item for key, item in legacy_input.items() if key != "boundary"},
},
}
model: Final = legacy_input.get("model")
if not isinstance(model, str):
return fixture
return {**fixture, "litellm_input": {"contract": _legacy_contract(model), **legacy_input}}
def _legacy_contract(model: str) -> str:
if model.startswith("azure_ai/doc-intelligence/"):
return "azure_document_intelligence"
if model.startswith("azure_ai/"):
return "azure_mistral"
if model.startswith("vertex_ai/deepseek"):
return "vertex_deepseek"
if model.startswith("vertex_ai/"):
return "vertex_mistral"
if model.endswith("parse-v3"):
return "reducto_v3"
if model.endswith("parse-legacy"):
return "reducto_legacy"
return "mistral"

View file

@ -0,0 +1,75 @@
from __future__ import annotations
import logging
import os
from collections.abc import Mapping
from typing import Final, cast
from dotenv import load_dotenv
import litellm
from litellm.rust_bridge.ocr import use_litellm_rust
from tests.route_parity.fixtures.cli import parse_recording_args
from tests.route_parity.fixtures.media import structured_image_data_uri
from tests.route_parity.fixtures.pipeline import record_fixtures
from tests.route_parity.fixtures.store import fixture_directory
from tests.test_litellm.ocr.fixtures.azure import (
azure_document_intelligence_recording_targets,
azure_mistral_recording_targets,
)
from tests.test_litellm.ocr.fixtures.base import OcrSdkInputBase
from tests.test_litellm.ocr.fixtures.common import OcrFixtureClient, OcrRecordingTarget, OcrSdkCall
from tests.test_litellm.ocr.fixtures.config import DEFAULT_FIXTURE_DIRECTORY, FIXTURE_DIR_ENV
from tests.test_litellm.ocr.fixtures.mistral import mistral_recording_targets
from tests.test_litellm.ocr.fixtures.models import OcrParityCase
from tests.test_litellm.ocr.fixtures.reducto import reducto_recording_targets
from tests.test_litellm.ocr.fixtures.vertex import vertex_recording_targets
class LiteLLMOcrFixtureClient:
def __init__(self, sdk_call: OcrSdkCall) -> None:
self.sdk_call: Final = sdk_call
def execute(self, api_base: str, api_key: str, case_input: OcrSdkInputBase) -> None:
self.sdk_call(api_base=api_base, api_key=api_key, **case_input.as_sdk_kwargs())
def discover_targets(
environ: Mapping[str, str],
client: OcrFixtureClient,
inline_image_data_uri: str,
) -> tuple[OcrRecordingTarget, ...]:
return (
*mistral_recording_targets(environ, client, inline_image_data_uri),
*azure_mistral_recording_targets(environ, client, inline_image_data_uri),
*azure_document_intelligence_recording_targets(environ, client),
*vertex_recording_targets(environ, client, inline_image_data_uri),
*reducto_recording_targets(environ, client, inline_image_data_uri),
)
def require_targets(targets: tuple[OcrRecordingTarget, ...]) -> tuple[OcrRecordingTarget, ...]:
if targets:
return targets
raise SystemExit("No OCR fixture providers are configured. Set a supported provider API key and endpoint")
def main() -> int:
logging.basicConfig(level=logging.INFO, format="%(message)s")
load_dotenv()
args: Final = parse_recording_args()
client: Final = LiteLLMOcrFixtureClient(cast(OcrSdkCall, litellm.ocr))
inline_image_data_uri: Final = structured_image_data_uri()
targets: Final = require_targets(discover_targets(os.environ, client, inline_image_data_uri))
root: Final = fixture_directory(
args.fixture_dir,
os.environ.get(FIXTURE_DIR_ENV),
DEFAULT_FIXTURE_DIRECTORY,
)
use_litellm_rust(False, ocr=None, aocr=None)
summary: Final = record_fixtures(targets, root, args.examples, args.concurrency, OcrParityCase)
return summary.exit_code
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,440 @@
from __future__ import annotations
import base64
import binascii
from collections.abc import Mapping
from typing import Annotated, Final, Literal, cast
from hypothesis import strategies as st
from hypothesis.strategies import SearchStrategy
from pydantic import Field, field_validator, model_validator
from typing_extensions import Self
from tests.route_parity.fixture_models import FixtureModel, JsonObject
from tests.route_parity.fixtures.media import structured_pdf_data_uri
from tests.route_parity.fixtures.recording import UpstreamEndpoint
from tests.test_litellm.ocr.fixtures.base import OcrSdkInputBase
from tests.test_litellm.ocr.fixtures.common import (
OcrFixtureClient,
OcrRecordingTarget,
image_data_document,
invoke_with_api_key,
)
def _validate_reducto_source(source: str) -> str:
if source.startswith("reducto://"):
return source
if not source.startswith("data:"):
raise ValueError("Reducto documents require a reducto:// id or base64 data URI")
try:
header, encoded = source.split(",", 1)
except ValueError as error:
raise ValueError("invalid Reducto data URI") from error
if ";base64" not in header:
raise ValueError("Reducto data URIs must be base64 encoded")
try:
base64.b64decode(encoded, validate=True)
except (binascii.Error, ValueError) as error:
raise ValueError("invalid Reducto base64 payload") from error
return source
class ReductoImageUrlDocument(FixtureModel):
type: Literal["image_url"]
image_url: str
@field_validator("image_url")
@classmethod
def validate_image_url(cls, value: str) -> str:
return _validate_reducto_source(value)
class ReductoDocumentUrlDocument(FixtureModel):
type: Literal["document_url"]
document_url: str
@field_validator("document_url")
@classmethod
def validate_document_url(cls, value: str) -> str:
return _validate_reducto_source(value)
ReductoDocument = Annotated[
ReductoImageUrlDocument | ReductoDocumentUrlDocument,
Field(discriminator="type"),
]
ReductoTableOutputFormat = Literal["html", "json", "md", "jsonbbox", "dynamic", "csv"]
ReductoReturnImage = Literal["figure", "table", "page"]
ReductoFormattingInclude = Literal[
"change_tracking",
"highlight",
"comments",
"hyperlinks",
"signatures",
"ignore_watermarks",
]
ReductoBlockType = Literal[
"Header",
"Footer",
"Title",
"Section Header",
"Page Number",
"List Item",
"Figure",
"Table",
"Key Value",
"Text",
"Comment",
"Signature",
]
_REDUCTO_FILTER_BLOCK_GROUPS: Final[tuple[tuple[ReductoBlockType, ...], ...]] = (
(),
("Header",),
("Header", "Footer", "Page Number"),
("Figure", "Table", "Key Value"),
)
_REDUCTO_RETURN_IMAGE_GROUPS: Final[tuple[tuple[ReductoReturnImage, ...], ...]] = (
(),
("figure",),
("table",),
("page",),
("figure", "table"),
)
class ReductoFormatting(FixtureModel):
add_page_markers: bool = False
table_output_format: ReductoTableOutputFormat = "dynamic"
merge_tables: bool = False
include: list[ReductoFormattingInclude] = Field(default_factory=list)
@field_validator("include")
@classmethod
def validate_unique_include(cls, value: list[ReductoFormattingInclude]) -> list[ReductoFormattingInclude]:
if len(value) != len(set(value)):
raise ValueError("formatting.include entries must be unique")
return value
class ReductoChunking(FixtureModel):
chunk_mode: Literal["variable", "section", "page", "disabled", "block", "page_sections"] = "disabled"
chunk_size: int | None = None
chunk_overlap: int = Field(default=0, ge=0)
@model_validator(mode="after")
def validate_chunking(self) -> Self:
if self.chunk_size is not None and self.chunk_size <= 0:
raise ValueError("chunk_size must be positive")
if self.chunk_size is not None and self.chunk_overlap >= self.chunk_size:
raise ValueError("chunk_overlap must be less than chunk_size")
return self
class ReductoRetrieval(FixtureModel):
chunking: ReductoChunking = Field(default_factory=ReductoChunking)
filter_blocks: list[ReductoBlockType] = Field(default_factory=list)
embedding_optimized: bool = False
@field_validator("filter_blocks")
@classmethod
def validate_unique_blocks(cls, value: list[ReductoBlockType]) -> list[ReductoBlockType]:
if len(value) != len(set(value)):
raise ValueError("retrieval.filter_blocks entries must be unique")
return value
class ReductoPageRange(FixtureModel):
start: int | None = Field(default=None, ge=1)
end: int | None = Field(default=None, ge=1)
@model_validator(mode="after")
def validate_range(self) -> Self:
if self.start is not None and self.end is not None and self.end < self.start:
raise ValueError("page range end must be greater than or equal to start")
return self
class ReductoTenantThrottling(FixtureModel):
tenant_id: str = Field(min_length=1, max_length=256)
max_share: float = Field(default=0.5, gt=0, le=1)
class ReductoHybridVpcSettings(FixtureModel):
environment: str | None = None
ReductoPageSelection = ReductoPageRange | list[ReductoPageRange] | list[int] | list[str]
ReductoV3Model = Literal["reducto/parse-v3", "parse-v3"]
ReductoLegacyModel = Literal["reducto/parse-legacy", "parse-legacy"]
_ReductoV3Route = Literal["qualified", "image", "unqualified"]
_ReductoLegacyRoute = Literal["qualified", "unqualified"]
REDUCTO_V3_MODELS: Final[tuple[Literal["reducto/parse-v3"], ...]] = ("reducto/parse-v3",)
REDUCTO_LEGACY_MODELS: Final[tuple[Literal["reducto/parse-legacy"], ...]] = ("reducto/parse-legacy",)
class ReductoSettings(FixtureModel):
model: Literal["r-1"] | None = None
ocr_system: Literal["standard", "legacy"] = "standard"
extraction_mode: Literal["ocr", "hybrid", "metadata"] = "hybrid"
force_url_result: bool = False
force_file_extension: str | None = None
return_ocr_data: bool = False
return_images: list[ReductoReturnImage] = Field(default_factory=list)
embed_pdf_metadata: bool = False
embed_pdf_metadata_dpi: int = Field(default=100, ge=50, le=250)
persist_results: bool = False
tenant_throttling: ReductoTenantThrottling | None = None
timeout: float | None = Field(default=None, gt=0)
page_range: ReductoPageSelection | None = None
document_password: str | None = None
hybrid_vpc: ReductoHybridVpcSettings = Field(default_factory=ReductoHybridVpcSettings)
@field_validator("return_images")
@classmethod
def validate_unique_images(cls, value: list[ReductoReturnImage]) -> list[ReductoReturnImage]:
if len(value) != len(set(value)):
raise ValueError("settings.return_images entries must be unique")
return value
class ReductoParseV3SdkInput(OcrSdkInputBase):
contract: Literal["reducto_v3"] = "reducto_v3"
model: ReductoV3Model
document: ReductoDocument
custom_llm_provider: Literal["reducto"] | None = None
formatting: ReductoFormatting = Field(default_factory=ReductoFormatting)
retrieval: ReductoRetrieval = Field(default_factory=ReductoRetrieval)
settings: ReductoSettings = Field(default_factory=ReductoSettings)
@model_validator(mode="after")
def validate_provider_routing(self) -> Self:
if self.model == "parse-v3" and self.custom_llm_provider != "reducto":
raise ValueError("unqualified Reducto models require custom_llm_provider='reducto'")
return self
class ReductoParseLegacySdkInput(OcrSdkInputBase):
contract: Literal["reducto_legacy"] = "reducto_legacy"
model: ReductoLegacyModel
document: ReductoDocument
custom_llm_provider: Literal["reducto"] | None = None
enhance: JsonObject | None = None
@model_validator(mode="after")
def validate_provider_routing(self) -> Self:
if self.model == "parse-legacy" and self.custom_llm_provider != "reducto":
raise ValueError("unqualified Reducto models require custom_llm_provider='reducto'")
return self
_REDUCTO_PROVIDER_REJECTED_DOCUMENT: Final = ReductoDocumentUrlDocument(
type="document_url",
document_url="reducto://invalid-document-for-parity",
)
REDUCTO_V3_PROVIDER_REJECTED_INPUTS: Final[tuple[ReductoParseV3SdkInput, ...]] = (
ReductoParseV3SdkInput(
model="reducto/parse-v3",
document=_REDUCTO_PROVIDER_REJECTED_DOCUMENT,
),
)
REDUCTO_LEGACY_PROVIDER_REJECTED_INPUTS: Final[tuple[ReductoParseLegacySdkInput, ...]] = (
ReductoParseLegacySdkInput(
model="reducto/parse-legacy",
document=_REDUCTO_PROVIDER_REJECTED_DOCUMENT,
),
)
_REDUCTO_API_BASE: Final = "https://platform.reducto.ai"
def _formatting_strategy() -> SearchStrategy[ReductoFormatting]:
values: Final = st.one_of(
st.sampled_from(("dynamic", "html", "md", "json", "csv", "jsonbbox")).map(
lambda value: {"table_output_format": value}
),
st.sampled_from((False, True)).map(lambda value: {"add_page_markers": value}),
st.sampled_from((False, True)).map(lambda value: {"merge_tables": value}),
st.sampled_from(
(
(),
("hyperlinks",),
("change_tracking", "highlight", "comments"),
("signatures", "ignore_watermarks"),
)
)
.map(list)
.map(lambda value: {"include": value}),
)
return values.map(ReductoFormatting.model_validate)
def _chunking_strategy() -> SearchStrategy[ReductoChunking]:
return st.one_of(
st.sampled_from(("disabled", "section", "page", "block", "page_sections")).map(
lambda mode: ReductoChunking(chunk_mode=mode)
),
st.just(ReductoChunking(chunk_mode="variable")),
st.sampled_from((250, 1000, 1500)).map(lambda size: ReductoChunking(chunk_mode="variable", chunk_size=size)),
st.sampled_from((32, 128)).map(
lambda overlap: ReductoChunking(chunk_mode="variable", chunk_size=1000, chunk_overlap=overlap)
),
)
def _retrieval_strategy() -> SearchStrategy[ReductoRetrieval]:
filter_blocks: Final = cast(
SearchStrategy[list[ReductoBlockType]],
st.sampled_from(_REDUCTO_FILTER_BLOCK_GROUPS).map(list),
)
return st.one_of(
_chunking_strategy().map(lambda chunking: ReductoRetrieval(chunking=chunking)),
filter_blocks.map(lambda selected_blocks: ReductoRetrieval(filter_blocks=selected_blocks)),
st.sampled_from((False, True)).map(
lambda optimized: ReductoRetrieval(
chunking=ReductoChunking(chunk_mode="variable"),
embedding_optimized=optimized,
)
),
)
def _settings_strategy() -> SearchStrategy[ReductoSettings]:
# force_url_result stays model-compatible but is not recorded until the
# response transform follows and downloads result.url.
return_images: Final[SearchStrategy[list[ReductoReturnImage]]] = st.sampled_from(_REDUCTO_RETURN_IMAGE_GROUPS).map(
list
)
page_ranges: Final = st.one_of(
st.just(ReductoPageRange(start=1, end=1)),
st.just(ReductoPageRange(start=1, end=3)),
st.sampled_from(
(
(
ReductoPageRange(start=1, end=2),
ReductoPageRange(start=4, end=5),
),
)
).map(list),
)
return st.one_of(
st.just(ReductoSettings(model="r-1")),
st.sampled_from(("standard", "legacy")).map(lambda value: ReductoSettings(ocr_system=value)),
st.sampled_from(("hybrid", "ocr", "metadata")).map(lambda value: ReductoSettings(extraction_mode=value)),
st.just(ReductoSettings(return_ocr_data=True)),
return_images.map(lambda selected_images: ReductoSettings(return_images=selected_images)),
st.just(ReductoSettings(embed_pdf_metadata=True)),
st.sampled_from((50, 100, 250)).map(
lambda dpi: ReductoSettings(embed_pdf_metadata=True, embed_pdf_metadata_dpi=dpi)
),
st.just(ReductoSettings(timeout=300.0)),
page_ranges.map(lambda page_range: ReductoSettings(page_range=page_range)),
)
def _reducto_v3_baseline(
route: _ReductoV3Route,
document: ReductoDocument,
inline_image_data_uri: str,
) -> ReductoParseV3SdkInput:
if route == "image":
inline_image: Final = ReductoImageUrlDocument.model_validate(
image_data_document(inline_image_data_uri).model_dump(mode="json")
)
return ReductoParseV3SdkInput(model="reducto/parse-v3", document=inline_image)
if route == "unqualified":
return ReductoParseV3SdkInput(
model="parse-v3",
custom_llm_provider="reducto",
document=document,
)
return ReductoParseV3SdkInput(model="reducto/parse-v3", document=document)
def reducto_v3_input_strategy(
inline_image_data_uri: str,
document: ReductoDocument | None = None,
) -> SearchStrategy[ReductoParseV3SdkInput]:
selected_document: Final = document or ReductoDocumentUrlDocument(
type="document_url", document_url="reducto://fixture-document.pdf"
)
baseline_routes: Final[tuple[_ReductoV3Route, ...]] = ("qualified", "image", "unqualified")
return st.one_of(
st.sampled_from(baseline_routes).map(
lambda route: _reducto_v3_baseline(route, selected_document, inline_image_data_uri)
),
_formatting_strategy().map(
lambda formatting: ReductoParseV3SdkInput(
model="reducto/parse-v3",
document=selected_document,
formatting=formatting,
)
),
_retrieval_strategy().map(
lambda retrieval: ReductoParseV3SdkInput(
model="reducto/parse-v3",
document=selected_document,
retrieval=retrieval,
)
),
_settings_strategy().map(
lambda settings: ReductoParseV3SdkInput(
model="reducto/parse-v3",
document=selected_document,
settings=settings,
)
),
)
def _reducto_legacy_input(
route: _ReductoLegacyRoute,
document: ReductoDocument,
) -> ReductoParseLegacySdkInput:
if route == "unqualified":
return ReductoParseLegacySdkInput(
model="parse-legacy",
custom_llm_provider="reducto",
document=document,
)
return ReductoParseLegacySdkInput(model="reducto/parse-legacy", document=document)
def reducto_legacy_input_strategy(
document: ReductoDocument | None = None,
) -> SearchStrategy[ReductoParseLegacySdkInput]:
selected_document: Final = document or ReductoDocumentUrlDocument(
type="document_url", document_url="reducto://fixture-document.pdf"
)
routes: Final[tuple[_ReductoLegacyRoute, ...]] = ("qualified", "unqualified")
return st.sampled_from(routes).map(lambda route: _reducto_legacy_input(route, selected_document))
def reducto_recording_targets(
environ: Mapping[str, str], client: OcrFixtureClient, inline_image_data_uri: str
) -> tuple[OcrRecordingTarget, ...]:
api_key: Final = environ.get("REDUCTO_API_KEY")
if not api_key:
return ()
base_url: Final = environ.get("REDUCTO_API_BASE", _REDUCTO_API_BASE).rstrip("/")
document: Final = ReductoDocumentUrlDocument(type="document_url", document_url=structured_pdf_data_uri())
invocation: Final = invoke_with_api_key(client, api_key)
return (
OcrRecordingTarget(
name="reducto-v3",
upstream=UpstreamEndpoint(base_url=base_url),
strategy=cast(SearchStrategy[OcrSdkInputBase], reducto_v3_input_strategy(inline_image_data_uri, document)),
invocation=invocation,
required_inputs=REDUCTO_V3_PROVIDER_REJECTED_INPUTS,
),
OcrRecordingTarget(
name="reducto-legacy",
upstream=UpstreamEndpoint(base_url=base_url),
strategy=cast(SearchStrategy[OcrSdkInputBase], reducto_legacy_input_strategy(document)),
invocation=invocation,
required_inputs=REDUCTO_LEGACY_PROVIDER_REJECTED_INPUTS,
),
)

View file

@ -0,0 +1,148 @@
from __future__ import annotations
from collections.abc import Mapping
from typing import Final, Literal, cast
from hypothesis import strategies as st
from hypothesis.strategies import DrawFn, SearchStrategy
from tests.route_parity.fixtures.recording import UpstreamEndpoint
from tests.test_litellm.ocr.fixtures.base import OcrDocument, OcrSdkInputBase
from tests.test_litellm.ocr.fixtures.common import (
OcrFixtureClient,
OcrRecordingTarget,
image_data_document,
invoke_with_api_key,
)
from tests.test_litellm.ocr.fixtures.mistral import (
MistralCompatibleOcrSdkInput,
mistral_input_values_strategy,
)
VertexMistralModel = Literal["vertex_ai/mistral-ocr-2505"]
VertexDeepSeekModel = Literal["vertex_ai/deepseek-ai/deepseek-ocr-maas"]
VertexMistralFixtureModel = VertexMistralModel | Literal["vertex_ai/invalid-ocr-model-for-parity"]
VertexDeepSeekFixtureModel = VertexDeepSeekModel | Literal["vertex_ai/deepseek-ai/invalid-ocr-model-for-parity"]
VERTEX_MISTRAL_MODELS: Final[tuple[VertexMistralModel, ...]] = ("vertex_ai/mistral-ocr-2505",)
VERTEX_DEEPSEEK_MODELS: Final[tuple[VertexDeepSeekModel, ...]] = ("vertex_ai/deepseek-ai/deepseek-ocr-maas",)
class VertexMistralOcrSdkInput(MistralCompatibleOcrSdkInput):
contract: Literal["vertex_mistral"] = "vertex_mistral"
model: VertexMistralFixtureModel = "vertex_ai/mistral-ocr-2505"
custom_llm_provider: Literal["vertex_ai"] | None = None
vertex_project: str
vertex_location: str = "us-central1"
class VertexDeepSeekOcrSdkInput(OcrSdkInputBase):
contract: Literal["vertex_deepseek"] = "vertex_deepseek"
model: VertexDeepSeekFixtureModel = "vertex_ai/deepseek-ai/deepseek-ocr-maas"
document: OcrDocument
custom_llm_provider: Literal["vertex_ai"] | None = None
vertex_project: str
vertex_location: str = "us-central1"
def vertex_mistral_provider_rejected_inputs(
project: str,
location: str,
inline_image_data_uri: str,
) -> tuple[VertexMistralOcrSdkInput, ...]:
return (
VertexMistralOcrSdkInput(
model="vertex_ai/invalid-ocr-model-for-parity",
document=image_data_document(inline_image_data_uri),
vertex_project=project,
vertex_location=location,
),
)
def vertex_deepseek_provider_rejected_inputs(
project: str,
location: str,
inline_image_data_uri: str,
) -> tuple[VertexDeepSeekOcrSdkInput, ...]:
return (
VertexDeepSeekOcrSdkInput(
model="vertex_ai/deepseek-ai/invalid-ocr-model-for-parity",
document=image_data_document(inline_image_data_uri),
vertex_project=project,
vertex_location=location,
),
)
def _as_vertex_mistral(
values: dict[str, object],
project: str,
location: str,
model: VertexMistralModel,
) -> VertexMistralOcrSdkInput:
return VertexMistralOcrSdkInput.model_validate(
{**values, "model": model, "vertex_project": project, "vertex_location": location}
)
def vertex_mistral_input_strategy(
project: str,
location: str,
inline_image_data_uri: str,
) -> SearchStrategy[VertexMistralOcrSdkInput]:
return st.builds(
_as_vertex_mistral,
project=st.just(project),
location=st.just(location),
model=st.sampled_from(VERTEX_MISTRAL_MODELS),
values=mistral_input_values_strategy("2505", inline_image_data_uri),
)
@st.composite
def vertex_deepseek_input_strategy(
draw: DrawFn, project: str, location: str, inline_image_data_uri: str
) -> VertexDeepSeekOcrSdkInput:
return VertexDeepSeekOcrSdkInput.model_validate(
{
"model": draw(st.sampled_from(VERTEX_DEEPSEEK_MODELS)),
"document": image_data_document(inline_image_data_uri),
"vertex_project": project,
"vertex_location": location,
}
)
def vertex_recording_targets(
environ: Mapping[str, str], client: OcrFixtureClient, inline_image_data_uri: str
) -> tuple[OcrRecordingTarget, ...]:
api_key: Final = environ.get("VERTEX_AI_API_KEY")
project: Final = environ.get("VERTEXAI_PROJECT") or environ.get("VERTEX_PROJECT")
location: Final = environ.get("VERTEXAI_LOCATION") or environ.get("VERTEX_LOCATION") or "us-central1"
if not api_key or not project:
return ()
base_url: Final = environ.get("VERTEX_AI_API_BASE") or f"https://{location}-aiplatform.googleapis.com"
invocation: Final = invoke_with_api_key(client, api_key)
return (
OcrRecordingTarget(
name="vertex-mistral",
upstream=UpstreamEndpoint(base_url=base_url.rstrip("/")),
strategy=cast(
SearchStrategy[OcrSdkInputBase],
vertex_mistral_input_strategy(project, location, inline_image_data_uri),
),
invocation=invocation,
required_inputs=vertex_mistral_provider_rejected_inputs(project, location, inline_image_data_uri),
),
OcrRecordingTarget(
name="vertex-deepseek",
upstream=UpstreamEndpoint(base_url=base_url.rstrip("/")),
strategy=cast(
SearchStrategy[OcrSdkInputBase],
vertex_deepseek_input_strategy(project, location, inline_image_data_uri),
),
invocation=invocation,
required_inputs=vertex_deepseek_provider_rejected_inputs(project, location, inline_image_data_uri),
),
)

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,85 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from queue import Queue
from typing import Final, Protocol, cast
import pytest
from tests.route_parity.fixtures.cassette import deserialize_cassette
from tests.route_parity.fixtures.pytest_support import parametrize_recorded_fixtures
from tests.route_parity.fixtures.store import FixtureEnvelope, read_fixture, recorded_fixtures
from tests.test_litellm.ocr.conftest import ocr_fixture_id, ocr_fixture_marks
from tests.test_litellm.ocr.fixtures.migrate import migrate_fixture
from tests.test_litellm.ocr.fixtures.models import OcrParityCase
class _Parameter(Protocol):
values: tuple[OcrParityCase, ...]
marks: tuple[pytest.Mark, ...]
@dataclass(frozen=True, slots=True)
class _MetafuncSpy:
fixturenames: tuple[str, ...]
calls: Queue[tuple[object, ...]]
def parametrize(self, *args: object, **_kwargs: object) -> None:
self.calls.put(args)
def test_recorded_fixture_parametrization_applies_case_specific_marks() -> None:
calls: Final[Queue[tuple[object, ...]]] = Queue()
metafunc: Final = _MetafuncSpy(fixturenames=("ocr_fixture",), calls=calls)
parametrize_recorded_fixtures(
cast(pytest.Metafunc, metafunc),
fixture_name="ocr_fixture",
case_type=OcrParityCase,
env_var="UNCONFIGURED_OCR_FIXTURE_TEST_DIRECTORY",
default_directory=Path(__file__).with_name("fixtures") / "data",
regeneration_command="unused",
id_builder=ocr_fixture_id,
marks_builder=ocr_fixture_marks,
)
parameters: Final = cast(tuple[_Parameter, ...], calls.get_nowait()[1])
reducto_parameters: Final = tuple(
parameter
for parameter in parameters
if parameter.values[0].litellm_input.contract in {"reducto_v3", "reducto_legacy"}
)
supported_parameters: Final = tuple(parameter for parameter in parameters if parameter not in reducto_parameters)
assert reducto_parameters
assert supported_parameters
assert all(len(parameter.marks) == 1 for parameter in reducto_parameters)
assert all(parameter.marks[0].name == "xfail" for parameter in reducto_parameters)
assert all(parameter.marks[0].kwargs["strict"] is False for parameter in reducto_parameters)
assert all(parameter.marks == () for parameter in supported_parameters)
def test_legacy_fixture_migration_preserves_responses_and_labels_reconstructed_requests(tmp_path: Path) -> None:
case: Final = recorded_fixtures(Path(__file__).with_name("fixtures") / "data" / "mistral-ocr", OcrParityCase)[0]
timestamp: Final = datetime(2020, 1, 1, tzinfo=timezone.utc)
envelope: Final = FixtureEnvelope(
schema_version=1,
recorded_at=timestamp,
case=case.model_dump(mode="json", exclude_unset=True),
)
legacy_path: Final = tmp_path / "legacy.json"
legacy_path.write_text(envelope.model_dump_json())
destination: Final = migrate_fixture(legacy_path)
assert not legacy_path.exists()
assert read_fixture(destination, OcrParityCase) == case
cassette: Final = deserialize_cassette(destination.read_text())
assert cassette.recorded_at == timestamp
assert cassette.parity.request_source == "python_replay"
assert len(cassette.interactions) == len(case.provider_responses)
assert cassette.interactions[0].request.method == "POST"
assert cassette.interactions[0].request.uri == "http://parity-provider.invalid/v1/ocr"
assert "authorization" not in cassette.interactions[0].request.headers

View file

@ -0,0 +1,366 @@
from __future__ import annotations
import queue
from collections.abc import Callable, Mapping
from dataclasses import dataclass
from pathlib import Path
from typing import Final, cast
import pytest
from hypothesis import find, settings
from hypothesis.strategies import SearchStrategy
from tests.route_parity.fixtures.cli import parse_recording_args
from tests.route_parity.fixtures.inputs import generate_case_inputs
from tests.route_parity.fixtures.media import structured_pdf_data_uri
from tests.test_litellm.ocr.fixtures.azure import (
AZURE_DOCUMENT_INTELLIGENCE_PROVIDER_REJECTED_INPUTS,
AZURE_DOCUMENT_INTELLIGENCE_RECORDING_MODELS,
AZURE_MISTRAL_MODELS,
AZURE_MISTRAL_PROVIDER_REJECTED_INPUTS,
)
from tests.test_litellm.ocr.fixtures.base import OcrSdkInputBase
from tests.test_litellm.ocr.fixtures.common import OcrFixtureClient, OcrRecordingTarget
from tests.test_litellm.ocr.fixtures.mistral import MISTRAL_MODELS, MISTRAL_PROVIDER_REJECTED_INPUTS
from tests.test_litellm.ocr.fixtures.record import (
discover_targets as discover_targets_with_media,
)
from tests.test_litellm.ocr.fixtures.record import (
require_targets,
)
from tests.test_litellm.ocr.fixtures.reducto import (
REDUCTO_LEGACY_MODELS,
REDUCTO_LEGACY_PROVIDER_REJECTED_INPUTS,
REDUCTO_V3_MODELS,
REDUCTO_V3_PROVIDER_REJECTED_INPUTS,
)
from tests.test_litellm.ocr.fixtures.vertex import (
VERTEX_DEEPSEEK_MODELS,
VERTEX_MISTRAL_MODELS,
vertex_deepseek_provider_rejected_inputs,
vertex_mistral_provider_rejected_inputs,
)
class _UnusedOcrClient:
def execute(self, api_base: str, api_key: str, case_input: OcrSdkInputBase) -> None:
raise AssertionError(f"unexpected SDK call to {api_base} with {api_key!r} and {case_input!r}")
@dataclass(frozen=True, slots=True)
class _RecordingOcrClient:
calls: queue.SimpleQueue[dict[str, object]]
def execute(self, api_base: str, api_key: str, case_input: OcrSdkInputBase) -> None:
self.calls.put({"api_base": api_base, "api_key": api_key, **case_input.as_sdk_kwargs()})
_UNUSED_OCR_CLIENT: Final = _UnusedOcrClient()
_MISTRAL_PARAMS: Final = frozenset(
{
"pages",
"include_image_base64",
"image_limit",
"image_min_size",
"bbox_annotation_format",
"document_annotation_format",
"document_annotation_prompt",
"extract_header",
"extract_footer",
"table_format",
"confidence_scores_granularity",
"include_blocks",
}
)
_MISTRAL_2512_PARAMS: Final = _MISTRAL_PARAMS - {"include_blocks"}
_MISTRAL_2505_PARAMS: Final = _MISTRAL_2512_PARAMS - {"extract_header", "extract_footer", "table_format"}
_AZURE_MISTRAL_PARAMS: Final = _MISTRAL_2505_PARAMS - {"document_annotation_prompt"}
_FIND_SETTINGS: Final = settings(max_examples=2_000, deadline=None, derandomize=True, database=None)
_INLINE_IMAGE_DATA_URI: Final = "data:image/png;base64,dGVzdA=="
def discover_targets(environ: Mapping[str, str], client: OcrFixtureClient) -> tuple[OcrRecordingTarget, ...]:
return discover_targets_with_media(environ, client, _INLINE_IMAGE_DATA_URI)
def _model(case_input: OcrSdkInputBase) -> str:
model: Final = case_input.canonical_input().get("model")
assert isinstance(model, str)
return model
def _find_input(
strategy: SearchStrategy[OcrSdkInputBase],
predicate: Callable[[OcrSdkInputBase], bool],
) -> OcrSdkInputBase:
return find(strategy, predicate, settings=_FIND_SETTINGS)
def _document_transport(case_input: OcrSdkInputBase) -> tuple[str, str]:
document: Final = cast(dict[str, object], case_input.canonical_input()["document"])
document_type: Final = cast(str, document["type"])
source: Final = document["image_url"] if document_type == "image_url" else document["document_url"]
assert isinstance(source, str)
return document_type, "data" if source.startswith("data:") else "remote"
def test_parse_args_has_no_model_selection() -> None:
args: Final = parse_recording_args(["--examples", "2", "--concurrency", "3", "--fixture-dir", "/tmp/ocr"])
assert args.examples == 2
assert args.concurrency == 3
assert args.fixture_dir == Path("/tmp/ocr")
with pytest.raises(SystemExit):
parse_recording_args(["--model", "mistral/mistral-ocr-latest"])
@pytest.mark.parametrize(
"environ",
(
{},
{"MISTRAL_API_KEY": ""},
{"LITELLM_API_KEY": "generic-key"},
),
)
def test_discovery_requires_provider_specific_key(environ: dict[str, str]) -> None:
assert discover_targets(environ, _UNUSED_OCR_CLIENT) == ()
def test_no_discovered_targets_has_actionable_error() -> None:
with pytest.raises(SystemExit, match="supported provider API key"):
require_targets(())
def test_discovery_is_explicit_per_available_provider_boundary() -> None:
targets: Final = discover_targets(
{
"MISTRAL_API_KEY": "mistral-secret",
"REDUCTO_API_KEY": "reducto-secret",
"AZURE_AI_API_KEY": "azure-secret",
"AZURE_AI_API_BASE": "https://azure.example",
"AZURE_DOCUMENT_INTELLIGENCE_API_KEY": "document-secret",
"AZURE_DOCUMENT_INTELLIGENCE_ENDPOINT": "https://document.example",
"VERTEX_AI_API_KEY": "vertex-secret",
"VERTEXAI_PROJECT": "project-1",
},
_UNUSED_OCR_CLIENT,
)
assert tuple(target.name for target in targets) == (
"mistral-ocr",
"azure-mistral",
"azure-document-intelligence",
"vertex-mistral",
"vertex-deepseek",
"reducto-v3",
"reducto-legacy",
)
assert all("secret" not in repr(target) for target in targets)
def test_azure_mistral_discovery_enumerates_registered_models() -> None:
environ: Final = {
"AZURE_AI_API_KEY": "azure-secret",
"AZURE_AI_API_BASE": "https://azure.example",
}
target: Final = discover_targets(environ, _UNUSED_OCR_CLIENT)[0]
for model in AZURE_MISTRAL_MODELS:
assert (
_model(
_find_input(
target.strategy,
lambda case_input, expected_model=model: _model(case_input) == expected_model,
)
)
== model
)
@pytest.mark.parametrize(
("configured", "expected"),
(
(None, "https://api.mistral.ai"),
("https://mistral.example/v1", "https://mistral.example"),
("https://mistral.example/", "https://mistral.example"),
),
)
def test_mistral_target_uses_canonical_model_and_normalized_base(
configured: str | None,
expected: str,
) -> None:
environ: Final = {
"MISTRAL_API_KEY": "mistral-secret",
**({"MISTRAL_API_BASE": configured} if configured is not None else {}),
}
targets: Final = discover_targets(environ, _UNUSED_OCR_CLIENT)
assert len(targets) == 1
target: Final = targets[0]
assert target.name == "mistral-ocr"
assert target.upstream.base_url == expected
assert "mistral-secret" not in repr(target)
case_inputs: Final = generate_case_inputs(target.strategy, examples=1)
assert len(case_inputs) == 1
assert case_inputs[0].canonical_input()["model"] in MISTRAL_MODELS
def test_mistral_target_invocation_forwards_discovered_credentials() -> None:
calls: Final[queue.SimpleQueue[dict[str, object]]] = queue.SimpleQueue()
client: Final = _RecordingOcrClient(calls)
target: Final = discover_targets({"MISTRAL_API_KEY": "mistral-secret"}, client)[0]
case_input: Final = generate_case_inputs(target.strategy, examples=1)[0]
target.invocation.execute("http://127.0.0.1:1234", case_input)
kwargs: Final = calls.get_nowait()
assert kwargs["api_base"] == "http://127.0.0.1:1234"
assert kwargs["api_key"] == "mistral-secret"
assert kwargs["model"] in MISTRAL_MODELS
def test_every_target_strategy_reaches_every_recording_model_and_coverage_param() -> None:
targets: Final = discover_targets(
{
"MISTRAL_API_KEY": "mistral-secret",
"REDUCTO_API_KEY": "reducto-secret",
"AZURE_AI_API_KEY": "azure-secret",
"AZURE_AI_API_BASE": "https://azure.example",
"AZURE_DOCUMENT_INTELLIGENCE_API_KEY": "document-secret",
"AZURE_DOCUMENT_INTELLIGENCE_ENDPOINT": "https://document.example",
"VERTEX_AI_API_KEY": "vertex-secret",
"VERTEXAI_PROJECT": "project-1",
},
_UNUSED_OCR_CLIENT,
)
expected: Final[dict[str, tuple[tuple[str, ...], frozenset[str]]]] = {
"mistral-ocr": (MISTRAL_MODELS, _MISTRAL_PARAMS),
"azure-mistral": (AZURE_MISTRAL_MODELS, _AZURE_MISTRAL_PARAMS),
"azure-document-intelligence": (
AZURE_DOCUMENT_INTELLIGENCE_RECORDING_MODELS,
frozenset({"pages", "features", "req_format"}),
),
"vertex-mistral": (VERTEX_MISTRAL_MODELS, _MISTRAL_2505_PARAMS),
"vertex-deepseek": (VERTEX_DEEPSEEK_MODELS, frozenset[str]()),
"reducto-v3": (REDUCTO_V3_MODELS, frozenset({"formatting", "retrieval", "settings"})),
"reducto-legacy": (REDUCTO_LEGACY_MODELS, frozenset[str]()),
}
for target in targets:
expected_models, expected_params = expected[target.name]
for model in expected_models:
assert (
_model(
_find_input(
target.strategy,
lambda case_input, expected_model=model: _model(case_input) == expected_model,
)
)
== model
)
for param in expected_params:
reached = _find_input(
target.strategy,
lambda case_input, expected_param=param: expected_param in case_input.as_sdk_kwargs(),
)
assert param in reached.as_sdk_kwargs()
document = cast(dict[str, object], reached.canonical_input()["document"])
assert document == {"type": "document_url", "document_url": structured_pdf_data_uri()}
@pytest.mark.parametrize("target_name", ("mistral-ocr", "azure-mistral", "vertex-mistral"))
def test_mistral_recording_targets_reach_every_transport_branch(target_name: str) -> None:
targets: Final = discover_targets(
{
"MISTRAL_API_KEY": "mistral-secret",
"AZURE_AI_API_KEY": "azure-secret",
"AZURE_AI_API_BASE": "https://azure.example",
"VERTEX_AI_API_KEY": "vertex-secret",
"VERTEXAI_PROJECT": "project-1",
},
_UNUSED_OCR_CLIENT,
)
target: Final = next(candidate for candidate in targets if candidate.name == target_name)
for transport in (
("image_url", "remote"),
("image_url", "data"),
("document_url", "remote"),
("document_url", "data"),
):
reached = _find_input(
target.strategy,
lambda case_input, expected=transport: _document_transport(case_input) == expected,
)
assert _document_transport(reached) == transport
def test_vertex_deepseek_recording_reaches_documented_image_branch() -> None:
targets: Final = discover_targets(
{
"VERTEX_AI_API_KEY": "vertex-secret",
"VERTEXAI_PROJECT": "project-1",
},
_UNUSED_OCR_CLIENT,
)
target: Final = next(candidate for candidate in targets if candidate.name == "vertex-deepseek")
case_input: Final = _find_input(
target.strategy,
lambda candidate: _document_transport(candidate) == ("image_url", "data"),
)
assert _document_transport(case_input) == ("image_url", "data")
def test_only_intentional_provider_failures_are_fixed_inputs() -> None:
targets: Final = discover_targets(
{
"MISTRAL_API_KEY": "mistral-secret",
"REDUCTO_API_KEY": "reducto-secret",
"AZURE_AI_API_KEY": "azure-secret",
"AZURE_AI_API_BASE": "https://azure.example",
"AZURE_DOCUMENT_INTELLIGENCE_API_KEY": "document-secret",
"AZURE_DOCUMENT_INTELLIGENCE_ENDPOINT": "https://document.example",
"VERTEX_AI_API_KEY": "vertex-secret",
"VERTEXAI_PROJECT": "project-1",
},
_UNUSED_OCR_CLIENT,
)
expected: Final[dict[str, tuple[OcrSdkInputBase, ...]]] = {
"mistral-ocr": MISTRAL_PROVIDER_REJECTED_INPUTS,
"azure-mistral": AZURE_MISTRAL_PROVIDER_REJECTED_INPUTS,
"azure-document-intelligence": AZURE_DOCUMENT_INTELLIGENCE_PROVIDER_REJECTED_INPUTS,
"vertex-mistral": vertex_mistral_provider_rejected_inputs("project-1", "us-central1", _INLINE_IMAGE_DATA_URI),
"vertex-deepseek": vertex_deepseek_provider_rejected_inputs("project-1", "us-central1", _INLINE_IMAGE_DATA_URI),
"reducto-v3": REDUCTO_V3_PROVIDER_REJECTED_INPUTS,
"reducto-legacy": REDUCTO_LEGACY_PROVIDER_REJECTED_INPUTS,
}
assert {target.name for target in targets} == expected.keys()
for target in targets:
assert target.required_inputs == expected[target.name]
generated: Final = generate_case_inputs(target.strategy, examples=20)
assert all(case_input not in target.required_inputs for case_input in generated)
def test_mistral_adapters_preserve_omitted_optional_params() -> None:
targets: Final = discover_targets(
{
"AZURE_AI_API_KEY": "azure-secret",
"AZURE_AI_API_BASE": "https://azure.example",
"VERTEX_AI_API_KEY": "vertex-secret",
"VERTEXAI_PROJECT": "project-1",
},
_UNUSED_OCR_CLIENT,
)
baselines: Final = tuple(
_find_input(
target.strategy,
lambda case_input: _MISTRAL_PARAMS.isdisjoint(case_input.as_sdk_kwargs()),
)
for target in targets
)
assert all(_MISTRAL_PARAMS.isdisjoint(baseline.as_sdk_kwargs()) for baseline in baselines)

View file

@ -368,6 +368,18 @@ def test_load_rust_ocr_uses_compiled_extension(monkeypatch):
def test_timeout_to_seconds_handles_float_timeout_and_none():
assert rust_bridge._timeout_to_seconds(12.5) == 12.5
assert rust_bridge._timeout_to_seconds(None) is None
def test_ocr_provider_error_uses_resolved_request_url():
error = rust_bridge._OcrProviderError(
429,
"rate limited",
"https://example.azure.com/documentintelligence/documentModels/read:analyze",
)
assert str(error.response.request.url) == (
"https://example.azure.com/documentintelligence/documentModels/read:analyze"
)
assert rust_bridge._timeout_to_seconds(httpx.Timeout(30.0, read=42.0)) == 42.0
@ -662,18 +674,34 @@ def test_ocr_routes_to_rust_when_enabled(fake_bridge):
assert call["optional_params"].get("include_image_base64") is True
def test_ocr_routes_azure_ai_to_rust_when_enabled(fake_bridge):
@pytest.mark.parametrize(
("model", "provider"),
(
("azure_ai/pixtral-12b-2409", "azure_ai"),
("vertex_ai/mistral-ocr-2505", "vertex_ai"),
),
)
def test_ocr_routes_supported_provider_to_rust(
fake_bridge: RecordingBridge,
model: str,
provider: str,
) -> None:
provider_kwargs: dict[str, object] = (
{"vertex_project": "project-1", "vertex_location": "us-central1"} if provider == "vertex_ai" else {}
)
response = litellm.ocr(
model="azure_ai/pixtral-12b-2409",
model=model,
document=DOCUMENT,
api_key="sk-test",
api_base="https://example.services.ai.azure.com",
api_base="https://example.com",
**provider_kwargs,
)
assert isinstance(response, OCRResponse)
assert len(fake_bridge.calls) == 1
assert fake_bridge.calls[0]["model"] == "pixtral-12b-2409"
assert fake_bridge.calls[0]["custom_llm_provider"] == "azure_ai"
call = fake_bridge.calls[0]
assert call["model"] == model.rsplit("/", 1)[-1]
assert call["custom_llm_provider"] == provider
def test_ocr_rust_path_converts_file_document_before_bridge(fake_bridge):
@ -822,6 +850,27 @@ def test_ocr_unsupported_provider_skips_rust(monkeypatch):
assert bridge.calls == []
def test_ocr_non_string_header_uses_python_path(monkeypatch):
bridge = RecordingBridge()
litellm.use_litellm_rust(True, ocr=bridge)
def fake_handler_ocr(**kwargs):
assert kwargs["headers"] == {"x-invalid": 1}
return OCRResponse(pages=[], model="mistral-ocr-latest", object="ocr")
monkeypatch.setattr(ocr_main.base_llm_http_handler, "ocr", fake_handler_ocr)
response = litellm.ocr(
model=MODEL,
document=DOCUMENT,
api_key="sk-test",
extra_headers={"x-invalid": 1},
)
assert isinstance(response, OCRResponse)
assert bridge.calls == []
def test_ocr_provider_configs_expose_api_key_env_vars():
from litellm.llms.azure_ai.ocr.document_intelligence.transformation import (
AzureDocumentIntelligenceOCRConfig,

View file

@ -0,0 +1,463 @@
from __future__ import annotations
import asyncio
import sys
import traceback
from collections.abc import Awaitable, Callable, Coroutine, Generator
from contextlib import contextmanager
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from typing import Final, cast
import pytest
from litellm.llms.base_llm.ocr.transformation import OCRResponse
from litellm.rust_bridge import get_native_bridge
from litellm.rust_bridge import ocr as rust_ocr_bridge
from litellm.rust_bridge.ocr import RustAocr, RustOcr
from tests.route_parity.compare import assert_model_parity, assert_parity, assert_request_parity
from tests.route_parity.fixtures.store import recorded_fixtures
from tests.route_parity.inprocess import run_in_process
from tests.route_parity.models import (
SDKCommand,
SDKError,
SDKReport,
SDKSuccess,
WorkerFailure,
WorkerResult,
WorkerSuccess,
sdk_error_report,
)
from tests.route_parity.replay import replay_server
from tests.route_parity.runner import (
ExecutionVariant,
SubprocessRunner,
SubprocessWorker,
execution_worker_pair,
parity_worker_main,
run_execution,
)
from tests.test_litellm.ocr.fixtures.config import configured_fixture_directory
from tests.test_litellm.ocr.fixtures.models import OcrParityCase, OcrSdkInput
API_KEY: Final = "test-key"
PYTHON_HTTP_SENTINEL: Final = "python-ocr-parity-fallback"
PYTHON_VARIANT: Final = ExecutionVariant(name="Python", environment=(("LITELLM_USE_RUST_OCR", "0"),))
RUST_VARIANT: Final = ExecutionVariant(name="Rust", environment=(("LITELLM_USE_RUST_OCR", "1"),))
class SDKRoute(str, Enum):
OCR = "ocr"
AOCR = "aocr"
@dataclass(frozen=True, slots=True)
class InvalidOcrCase:
name: str
model: str
document: object
expected_exception_type: str
expected_status_code: int
expected_message: str
extra_kwargs: tuple[tuple[str, object], ...] = ()
expected_rust_calls: int = 0
INVALID_OCR_CASES: Final = (
InvalidOcrCase(
name="unsupported_provider",
model="openai/gpt-4o",
document={"type": "document_url", "document_url": "data:application/pdf;base64,AA=="},
expected_exception_type="litellm.exceptions.APIConnectionError",
expected_status_code=500,
expected_message="OCR is not supported for provider: openai",
),
InvalidOcrCase(
name="unsupported_reducto_model",
model="reducto/parse-v4",
document={"type": "document_url", "document_url": "data:application/pdf;base64,AA=="},
expected_exception_type="litellm.exceptions.APIConnectionError",
expected_status_code=500,
expected_message="OCR is not supported for provider: reducto",
),
InvalidOcrCase(
name="unknown_provider_prefix",
model="not_a_provider/model",
document={"type": "document_url", "document_url": "data:application/pdf;base64,AA=="},
expected_exception_type="litellm.exceptions.BadRequestError",
expected_status_code=400,
expected_message="LLM Provider NOT provided",
),
InvalidOcrCase(
name="non_object_document",
model="mistral/mistral-ocr-latest",
document=[],
expected_exception_type="litellm.exceptions.APIConnectionError",
expected_status_code=500,
expected_message="document must be a dict",
),
InvalidOcrCase(
name="missing_document_type",
model="mistral/mistral-ocr-latest",
document={},
expected_exception_type="litellm.exceptions.APIConnectionError",
expected_status_code=500,
expected_message="Invalid document type: None",
),
InvalidOcrCase(
name="unsupported_document_type",
model="mistral/mistral-ocr-latest",
document={"type": "text", "text": "not a document"},
expected_exception_type="litellm.exceptions.APIConnectionError",
expected_status_code=500,
expected_message="Invalid document type: text",
),
InvalidOcrCase(
name="missing_document_url",
model="azure_ai/doc-intelligence/prebuilt-read",
document={"type": "document_url"},
expected_exception_type="litellm.exceptions.APIConnectionError",
expected_status_code=500,
expected_message="Document URL is required",
expected_rust_calls=1,
),
InvalidOcrCase(
name="missing_image_url",
model="azure_ai/doc-intelligence/prebuilt-read",
document={"type": "image_url"},
expected_exception_type="litellm.exceptions.APIConnectionError",
expected_status_code=500,
expected_message="Document URL is required",
expected_rust_calls=1,
),
InvalidOcrCase(
name="invalid_request_format",
model="mistral/mistral-ocr-latest",
document={"type": "document_url", "document_url": "data:application/pdf;base64,AA=="},
expected_exception_type="litellm.exceptions.UnsupportedParamsError",
expected_status_code=400,
expected_message="Invalid `req_format`: 'bogus'",
extra_kwargs=(("req_format", "bogus"),),
),
InvalidOcrCase(
name="invalid_document_intelligence_pages",
model="azure_ai/doc-intelligence/prebuilt-read",
document={"type": "document_url", "document_url": "data:application/pdf;base64,AA=="},
expected_exception_type="litellm.exceptions.APIConnectionError",
expected_status_code=500,
expected_message="`pages` integers must be >= 0",
extra_kwargs=(("pages", [-1]),),
),
InvalidOcrCase(
name="invalid_document_intelligence_features",
model="azure_ai/doc-intelligence/prebuilt-read",
document={"type": "document_url", "document_url": "data:application/pdf;base64,AA=="},
expected_exception_type="litellm.exceptions.APIConnectionError",
expected_status_code=500,
expected_message="Invalid `features` for Azure Document Intelligence",
extra_kwargs=(("features", [1]),),
),
)
def _call_kwargs(sdk_input: OcrSdkInput, mock_url: str, route: SDKRoute) -> dict[str, object]:
return {
**sdk_input.as_sdk_kwargs(),
"api_base": mock_url,
"api_key": API_KEY,
"extra_headers": {"x-litellm-parity-route": route.value},
}
def _execute_sdk_call(
call_kwargs: dict[str, object],
route: SDKRoute,
event_loop: asyncio.AbstractEventLoop,
) -> SDKReport:
import litellm
try:
if route is SDKRoute.OCR:
sync_route: Final = cast(Callable[..., OCRResponse], litellm.ocr)
response: Final = sync_route(**call_kwargs)
return SDKSuccess(response=response.model_dump(mode="json"))
async_route: Final = cast(Callable[..., Coroutine[object, object, OCRResponse]], litellm.aocr)
async_response: Final = event_loop.run_until_complete(async_route(**call_kwargs))
return SDKSuccess(response=async_response.model_dump(mode="json"))
except Exception as error:
return sdk_error_report(error)
def _execute_sdk_case(
sdk_input: OcrSdkInput,
route: SDKRoute,
mock_url: str,
event_loop: asyncio.AbstractEventLoop,
) -> SDKReport:
call_kwargs: Final = _call_kwargs(sdk_input, mock_url, route)
return _execute_sdk_call(call_kwargs, route, event_loop)
def _execute_recorded_sdk_case(
sdk_input: OcrSdkInput,
route: SDKRoute,
mock_url: str,
event_loop: asyncio.AbstractEventLoop,
) -> OCRResponse | SDKError:
import litellm
call_kwargs: Final = _call_kwargs(sdk_input, mock_url, route)
try:
if route is SDKRoute.OCR:
sync_route: Final = cast(Callable[..., OCRResponse], litellm.ocr)
return sync_route(**call_kwargs)
async_route: Final = cast(Callable[..., Coroutine[object, object, OCRResponse]], litellm.aocr)
return event_loop.run_until_complete(async_route(**call_kwargs))
except Exception as error:
return sdk_error_report(error)
def _execute_invalid_sdk_case(
case: InvalidOcrCase,
route: SDKRoute,
mock_url: str,
event_loop: asyncio.AbstractEventLoop,
) -> SDKReport:
call_kwargs: Final = {
"model": case.model,
"document": case.document,
"api_base": mock_url,
"api_key": API_KEY,
"extra_headers": {"x-litellm-parity-route": route.value},
**dict(case.extra_kwargs),
}
return _execute_sdk_call(call_kwargs, route, event_loop)
class _RustOcrSpy:
def __init__(self, delegate: RustOcr) -> None:
self.delegate: Final = delegate
self.calls = 0
def __call__(
self,
model: str,
document: dict[str, object],
api_key: str | None,
api_base: str | None,
custom_llm_provider: str | None,
extra_headers: dict[str, object] | None,
optional_params: dict[str, object],
timeout_seconds: float | None,
) -> dict[str, object]:
self.calls += 1
return self.delegate(
model=model,
document=document,
api_key=api_key,
api_base=api_base,
custom_llm_provider=custom_llm_provider,
extra_headers=extra_headers,
optional_params=optional_params,
timeout_seconds=timeout_seconds,
)
class _RustAocrSpy:
def __init__(self, delegate: RustAocr) -> None:
self.delegate: Final = delegate
self.calls = 0
async def __call__(
self,
model: str,
document: dict[str, object],
api_key: str | None,
api_base: str | None,
custom_llm_provider: str | None,
extra_headers: dict[str, object] | None,
optional_params: dict[str, object],
timeout_seconds: float | None,
) -> dict[str, object]:
self.calls += 1
result: Final[Awaitable[dict[str, object]]] = self.delegate(
model=model,
document=document,
api_key=api_key,
api_base=api_base,
custom_llm_provider=custom_llm_provider,
extra_headers=extra_headers,
optional_params=optional_params,
timeout_seconds=timeout_seconds,
)
return await result
@contextmanager
def _restore_rust_ocr_state() -> Generator[None]:
from litellm.rust_bridge import configuration
config: Final = configuration._CONFIGURATION # pyright: ignore[reportPrivateUsage] # restore test state
sync_binding: Final = rust_ocr_bridge._OCR # pyright: ignore[reportPrivateUsage] # restore test state
async_binding: Final = rust_ocr_bridge._AOCR # pyright: ignore[reportPrivateUsage] # restore test state
enabled: Final = config.override
ocr_impl: Final = sync_binding._override # pyright: ignore[reportPrivateUsage] # preserve unset binding
aocr_impl: Final = async_binding._override # pyright: ignore[reportPrivateUsage] # preserve unset binding
try:
yield
finally:
config.override = enabled
sync_binding._override = ocr_impl # pyright: ignore[reportPrivateUsage] # restore exact binding state
async_binding._override = aocr_impl # pyright: ignore[reportPrivateUsage] # restore exact binding state
def _native_spies() -> tuple[_RustOcrSpy, _RustAocrSpy]:
native_bridge: Final = get_native_bridge()
if native_bridge is None:
pytest.fail("native Rust bridge is required for OCR parity testing")
sync_spy: Final = _RustOcrSpy(cast(RustOcr, getattr(native_bridge, "ocr")))
async_spy: Final = _RustAocrSpy(cast(RustAocr, getattr(native_bridge, "aocr")))
return sync_spy, async_spy
@pytest.fixture(scope="module")
def sdk_workers() -> Generator[tuple[SubprocessWorker, SubprocessWorker]]:
runner: Final = SubprocessRunner(
entrypoint=Path(__file__),
baseline_user_agent=PYTHON_HTTP_SENTINEL,
route_label="OCR",
)
with execution_worker_pair(runner, PYTHON_VARIANT, RUST_VARIANT) as workers:
yield workers
@pytest.fixture(scope="module")
def startup_ocr_fixture() -> OcrParityCase:
directory: Final = configured_fixture_directory()
fixtures: Final = recorded_fixtures(directory, OcrParityCase)
if not fixtures:
pytest.skip(f"no recorded fixtures in {directory}")
return fixtures[0]
@pytest.mark.parametrize("route", tuple(SDKRoute), ids=tuple(route.value for route in SDKRoute))
def test_recorded_ocr_sdk_parity(
ocr_fixture: OcrParityCase,
route: SDKRoute,
) -> None:
sync_spy, async_spy = _native_spies()
event_loop: Final = asyncio.new_event_loop()
try:
with _restore_rust_ocr_state(), replay_server() as provider:
rust_ocr_bridge.set_rust_ocr(ocr=sync_spy, aocr=async_spy)
rust_ocr_bridge.use_litellm_rust(False)
python: Final = run_in_process(
provider,
ocr_fixture.provider_responses,
lambda mock_url: _execute_recorded_sdk_case(ocr_fixture.litellm_input, route, mock_url, event_loop),
)
assert sync_spy.calls == 0
assert async_spy.calls == 0
rust_ocr_bridge.use_litellm_rust(True)
rust: Final = run_in_process(
provider,
ocr_fixture.provider_responses,
lambda mock_url: _execute_recorded_sdk_case(ocr_fixture.litellm_input, route, mock_url, event_loop),
)
finally:
event_loop.close()
assert sync_spy.calls == (1 if route is SDKRoute.OCR else 0)
assert async_spy.calls == (1 if route is SDKRoute.AOCR else 0)
assert_request_parity(python.requests, rust.requests)
if any(response.status_code >= 400 for response in ocr_fixture.provider_responses):
assert isinstance(python.response, SDKError)
if isinstance(python.response, SDKError):
assert python.response == rust.response
else:
assert isinstance(rust.response, OCRResponse)
assert_model_parity(python.response, rust.response)
@pytest.mark.parametrize("case", INVALID_OCR_CASES, ids=tuple(case.name for case in INVALID_OCR_CASES))
@pytest.mark.parametrize("route", tuple(SDKRoute), ids=tuple(route.value for route in SDKRoute))
def test_invalid_ocr_sdk_parity(case: InvalidOcrCase, route: SDKRoute) -> None:
sync_spy, async_spy = _native_spies()
event_loop: Final = asyncio.new_event_loop()
try:
with _restore_rust_ocr_state(), replay_server() as provider:
rust_ocr_bridge.set_rust_ocr(ocr=sync_spy, aocr=async_spy)
rust_ocr_bridge.use_litellm_rust(False)
python: Final = run_in_process(
provider,
(),
lambda mock_url: _execute_invalid_sdk_case(case, route, mock_url, event_loop),
)
assert sync_spy.calls == 0
assert async_spy.calls == 0
rust_ocr_bridge.use_litellm_rust(True)
rust: Final = run_in_process(
provider,
(),
lambda mock_url: _execute_invalid_sdk_case(case, route, mock_url, event_loop),
)
finally:
event_loop.close()
assert sync_spy.calls == (case.expected_rust_calls if route is SDKRoute.OCR else 0)
assert async_spy.calls == (case.expected_rust_calls if route is SDKRoute.AOCR else 0)
assert python.requests == ()
assert rust.requests == ()
assert python.response == rust.response
assert isinstance(python.response, SDKError)
assert python.response.exception_type == case.expected_exception_type
assert python.response.status_code == case.expected_status_code
assert case.expected_message in python.response.message
def test_ocr_subprocess_startup_smoke(
startup_ocr_fixture: OcrParityCase,
tmp_path: Path,
sdk_workers: tuple[SubprocessWorker, SubprocessWorker],
) -> None:
case_file: Final = tmp_path / "ocr-startup-smoke.json"
case_file.write_text(startup_ocr_fixture.model_dump_json(indent=2, exclude_unset=True), encoding="utf-8")
python_worker, rust_worker = sdk_workers
python: Final = run_execution(
python_worker,
case_file,
SDKRoute.OCR.value,
startup_ocr_fixture.provider_responses,
)
rust: Final = run_execution(
rust_worker,
case_file,
SDKRoute.OCR.value,
startup_ocr_fixture.provider_responses,
)
assert_parity(python, rust, PYTHON_HTTP_SENTINEL)
def _execute_worker_command(
command_json: str,
mock_url: str,
event_loop: asyncio.AbstractEventLoop,
) -> WorkerResult:
try:
command: Final = SDKCommand.model_validate_json(command_json)
case_file: Final = Path(command.case_file)
route: Final = SDKRoute(command.route)
case: Final = OcrParityCase.model_validate_json(case_file.read_text(encoding="utf-8"))
return WorkerSuccess(report=_execute_sdk_case(case.litellm_input, route, mock_url, event_loop))
except Exception:
return WorkerFailure(error=traceback.format_exc())
if __name__ == "__main__":
if len(sys.argv) != 3 or sys.argv[1] != "--parity-worker":
raise SystemExit("usage: test_sdk_parity.py --parity-worker MOCK_URL")
parity_worker_main(_execute_worker_command, sys.argv[2])

View file

@ -41,7 +41,7 @@ class FakeRedisCache(RedisCache):
Records the ``ttl`` kwarg DualCache forwards on each Redis write for tests.
"""
def __init__(self): # noqa: super().__init__ skipped intentionally
def __init__(self):
self._store: dict[str, str] = {}
self.last_ttl: Any = None

View file

@ -2,6 +2,7 @@ from __future__ import annotations
from types import SimpleNamespace
import httpx
import pytest
from litellm.exceptions import APIError
@ -120,3 +121,59 @@ def test_required_mode_rejects_unavailable_bridge() -> None:
mode=runtime.FallbackMode.RUST_REQUIRED,
context=context(),
)
@pytest.mark.parametrize("asynchronous", (False, True), ids=("sync", "async"))
@pytest.mark.asyncio
async def test_upstream_error_adapter_preserves_response_without_fallback(asynchronous: bool) -> None:
request = httpx.Request("POST", "https://example.com/ocr")
def provider_error(status: int, message: str) -> Exception:
return httpx.HTTPStatusError(
message,
request=request,
response=httpx.Response(status, request=request),
)
error_context = runtime.BridgeErrorContext(
route="ocr", provider="mistral", model="model", upstream_error=provider_error
)
def fail() -> str:
raise RustUpstreamError(429, '{"message":"rate limited"}')
async def afail() -> str:
return fail()
def fallback() -> str:
pytest.fail("provider failure must not execute Python fallback")
async def afallback() -> str:
return fallback()
async def invoke() -> None:
if asynchronous:
await runtime.ainvoke(
native_call=afail,
fallback=afallback,
adapt=runtime.identity,
mode=runtime.FallbackMode.PYTHON,
context=error_context,
)
else:
runtime.invoke(
native_call=fail,
fallback=fallback,
adapt=runtime.identity,
mode=runtime.FallbackMode.PYTHON,
context=error_context,
)
with pytest.raises(httpx.HTTPStatusError) as caught:
await invoke()
assert str(caught.value) == '{"message":"rate limited"}'
assert caught.value.response.status_code == 429
assert caught.value.request is request
assert isinstance(caught.value.__cause__, RustUpstreamError)
assert caught.value.headers == {"x-litellm-core": "rust", "x-litellm-rust": "true"}

109
uv.lock generated
View file

@ -3373,6 +3373,98 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" },
]
[[package]]
name = "hypothesis"
version = "6.165.10"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
{ name = "sortedcontainers" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5c/e2/0fad246d2b6330e1f78479bfc566b5c22be82aee8a865cde9a08f648487d/hypothesis-6.165.10.tar.gz", hash = "sha256:68b45e09834cd80523cb1eb274463073c7a9af4e4ef7cff34d9615f355572d32", size = 503703, upload-time = "2026-08-16T22:56:15.404Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/05/c1/9a9538e6d185baf5cc7f15bc3b76e08efbb3de4b3c782f234356449c0dd7/hypothesis-6.165.10-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:f839d29d0cc12048cf073d88ca4fdf94d420bc2b8afd69641ff6d496422ccd4f", size = 783243, upload-time = "2026-08-16T22:55:44.058Z" },
{ url = "https://files.pythonhosted.org/packages/a1/30/b70d9d79e871a75cbdeccd9067f20ecdb9eb2a1dfa03c630be3ad13b8b30/hypothesis-6.165.10-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:e10858f57ed0e74baa04393845f469fe8ad502c16ece4499bef7700c575611bd", size = 778815, upload-time = "2026-08-16T22:55:46.948Z" },
{ url = "https://files.pythonhosted.org/packages/db/52/6f0a9b7aab24b0635e2238f3fbddea5b54b17879ac813df42a3cc3384c5c/hypothesis-6.165.10-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76a7be86d986223b9f1bdb7e7cbcdb048649901fdb956c598ef73bdab1786cd5", size = 1108009, upload-time = "2026-08-16T22:54:53.082Z" },
{ url = "https://files.pythonhosted.org/packages/f6/06/8d0d4e11ff02350d09ec9f9e90af354158e59e16a8907ba5199a4ff2d7e8/hypothesis-6.165.10-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:717aea574e0e5edba2868aa66b1caae335d8f1ad3fb29f01dd6502953fa823a1", size = 1136596, upload-time = "2026-08-16T22:54:54.443Z" },
{ url = "https://files.pythonhosted.org/packages/59/dd/01a1e440f2e38dc1ccf5d597af5b8a0bee5f21b674c99c123b5554de9690/hypothesis-6.165.10-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4334058033e0214475f019e15492a50f3854fe8728cf51fe25c6191a2c3f8e52", size = 1135234, upload-time = "2026-08-16T22:55:08.911Z" },
{ url = "https://files.pythonhosted.org/packages/7d/18/8a26c24d3d9db20265f39df341ab265858c094e209571e3179cf237935f4/hypothesis-6.165.10-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2abb50cf1cf77d721de0a24c3f99d9c4ffdeb2cbd1e12aebb5a7a93e2b6b6d1f", size = 1157528, upload-time = "2026-08-16T22:56:02.159Z" },
{ url = "https://files.pythonhosted.org/packages/ea/8e/ce3c829b1937402d7944420ca26a05a0c8563e894dcff03d34ffa279d306/hypothesis-6.165.10-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:3de69aa8b924b400291a3cc42aaf78e6ab65c905a3e7e1a5dc39d95ef1b428cb", size = 1112870, upload-time = "2026-08-16T22:54:55.919Z" },
{ url = "https://files.pythonhosted.org/packages/f2/1b/4c4926d6c9a2b5d7cc090cc1e91219d6796102aa2a2c4b8f961c939e60b5/hypothesis-6.165.10-cp310-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5841331c504e02d7c334591681cb8587cdd59dee7e149db6d3db8e3f9e9f02eb", size = 1149683, upload-time = "2026-08-16T22:55:30.567Z" },
{ url = "https://files.pythonhosted.org/packages/cb/f9/df24eb28412f82465e2b7707f0ff1ec274d580bce389d4d9156617dc7bba/hypothesis-6.165.10-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2d0e0f8263d34dd8fa3b39eaa9a50bba56a8470b3dd9ebf6672d10840abe063e", size = 1283402, upload-time = "2026-08-16T22:54:18.054Z" },
{ url = "https://files.pythonhosted.org/packages/4d/07/c2b2a761300cf60b90ccebba4328175331e67d34f4fbd39429a7ddcdce49/hypothesis-6.165.10-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:0c4e6869817c3cfdf5a2b4d348497b95159bdecb3365be732c9b8570e36a4eef", size = 1409948, upload-time = "2026-08-16T22:54:22.343Z" },
{ url = "https://files.pythonhosted.org/packages/f4/ec/1c2bf1acdd0e273d81f833f85caf0ae5423db68a783554992fca36e6c541/hypothesis-6.165.10-cp310-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:9f07ae36c3b093e13687a894e79fe69e98a94c0b67fef656c575247682218143", size = 1265023, upload-time = "2026-08-16T22:54:41.402Z" },
{ url = "https://files.pythonhosted.org/packages/3d/a8/7f984908b7391160c7801b84e51ca8e4ba88c89e8d8811aa1aa7c03de73c/hypothesis-6.165.10-cp310-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:aff1f584c9538e8979cd180b1d70bf99bc16be19d4666414f49e5942b21a4f2c", size = 1282698, upload-time = "2026-08-16T22:56:06.998Z" },
{ url = "https://files.pythonhosted.org/packages/48/78/3a5d91c2d0250521736c42dfa2402b75049bc5fe2fb716c10bc84bb91ed1/hypothesis-6.165.10-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1f2c4db25fb8ec1a16a8dba580666337b8ffb1887c4cf1750cc954313897cef7", size = 1324816, upload-time = "2026-08-16T22:54:46.675Z" },
{ url = "https://files.pythonhosted.org/packages/6f/99/27450763853a034bca1574d3e0a315164b33ff49c3862df6872dda45e25e/hypothesis-6.165.10-cp310-abi3-win32.whl", hash = "sha256:b33dc30170a7402e03c180f2c5ef69dc077152f35b91621e9cebcde9c7d71746", size = 669039, upload-time = "2026-08-16T22:55:11.962Z" },
{ url = "https://files.pythonhosted.org/packages/2c/fc/ff2988b72b5705ad9ca500444bf3f43e3c2f41edfa034bbfeb23b215791a/hypothesis-6.165.10-cp310-abi3-win_amd64.whl", hash = "sha256:e9f924aa610c0618445e1e8738c822c3190ce2a2699a0cb48ec3a351a96761f2", size = 675213, upload-time = "2026-08-16T22:55:01.697Z" },
{ url = "https://files.pythonhosted.org/packages/c5/8b/821810d36f78d9d9421cd2c5d9d36983b45bb3575c3086276cc5c76f9f73/hypothesis-6.165.10-cp310-abi3-win_arm64.whl", hash = "sha256:1d305448e9bd8e2f4f3cea0eafd809efdaab4e998a0019bc615650c8463e42f1", size = 673537, upload-time = "2026-08-16T22:54:47.898Z" },
{ url = "https://files.pythonhosted.org/packages/26/61/5e89268ce03317fb9f82449a1b3efd9e599dee090288fd0cf7586c532fb1/hypothesis-6.165.10-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:73e6df02a6a62f8045b511c272f894d08e56d174504c793c9effcbc6778051a8", size = 783959, upload-time = "2026-08-16T22:55:29.078Z" },
{ url = "https://files.pythonhosted.org/packages/e1/e9/f4e0832e81bb53b70cf1712e28c867db64245b32595b594217452e7dbd8d/hypothesis-6.165.10-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8b20f44773a9ab84400465e318712d8c2ca16418d35b9f80aa27fdf2d690ad10", size = 779684, upload-time = "2026-08-16T22:54:57.698Z" },
{ url = "https://files.pythonhosted.org/packages/77/de/ea072d3359d5678771bed407f80439e8ac7ca905d1031b0372f61bf5746e/hypothesis-6.165.10-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb8c7d05ea27a093a92b250904095d71d924b6b44e5795a415c1b20c265f0c65", size = 1108540, upload-time = "2026-08-16T22:55:03.282Z" },
{ url = "https://files.pythonhosted.org/packages/28/56/e7c395cdaa3d6c28b944c1c3c516dee50d2b7b3aeafa31874b57009ca51f/hypothesis-6.165.10-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f4dafd6d6ababfa3b14dd6e5f0378cb7c7d291895a31a40abcbb7cc74f396131", size = 1158089, upload-time = "2026-08-16T22:54:36.205Z" },
{ url = "https://files.pythonhosted.org/packages/b1/49/1c6d2c465b9c5fc3213f1be89be95ba53819ca0130248c484129ccfefb71/hypothesis-6.165.10-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:fa74636a49fc8077413ce8db3e85f1c4aff880788bb55bda56253118e036fe5b", size = 1284125, upload-time = "2026-08-16T22:54:37.727Z" },
{ url = "https://files.pythonhosted.org/packages/7d/bc/7caf5ac3d0173bd57bd2a5ab854ca49a3664a4309257be1452f81025cc24/hypothesis-6.165.10-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2b112768cfb67f2b683e53e58c1a33d27811aacf60c942b8eb74635e469a73f6", size = 1325082, upload-time = "2026-08-16T22:54:38.952Z" },
{ url = "https://files.pythonhosted.org/packages/70/99/9d844330f570d6a4f127a683eab1e78c8263e6e72b16189f3534fa6bf6de/hypothesis-6.165.10-cp310-cp310-win_amd64.whl", hash = "sha256:56cb8c9055e50545fe6e3e5a560ec25a724673b2e4051f3c24d44e3ebc35dd72", size = 675082, upload-time = "2026-08-16T22:54:29.872Z" },
{ url = "https://files.pythonhosted.org/packages/ed/c2/b9546ace11f241c9c02d389f258cb80c14447a8c885771c9f1f0bc1d85ca/hypothesis-6.165.10-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:592107a0faf6c9c3a63a8dbf13dfb1cbda1cf599b0bc11c953221b00204b9ce1", size = 783716, upload-time = "2026-08-16T22:55:36.624Z" },
{ url = "https://files.pythonhosted.org/packages/37/10/27c2fdd574fd798caf5e91eb51f7834b098f5d840ce733efb3fba79ef86e/hypothesis-6.165.10-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f9180c362bde06fd05380298ded4e234fbc0d6ede0a864835bfd91c1e24283d5", size = 779507, upload-time = "2026-08-16T22:55:07.633Z" },
{ url = "https://files.pythonhosted.org/packages/5e/b6/70bc23695f3783c4b0486b6cad47b08a20f791db4a3c1b25250add9659fa/hypothesis-6.165.10-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d623801ae3dcd97b77b983400ef3d48bf976648e4efff19929175322eaae074d", size = 1108406, upload-time = "2026-08-16T22:55:39.653Z" },
{ url = "https://files.pythonhosted.org/packages/71/4c/32e200bd7a352af4b7f4e3729aaa4cd002cb5fe8c4c6aef5599d0019f152/hypothesis-6.165.10-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:20f6236cfb90b7817bb1a6a087589ca4aa46d73170f0dd62963952ed5dadc589", size = 1157850, upload-time = "2026-08-16T22:55:24.394Z" },
{ url = "https://files.pythonhosted.org/packages/03/a5/8efc2a9a484822efc0d0da466f50094e0f2c068187faaf33831fc905873e/hypothesis-6.165.10-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ad0764730e8e3421601c2cc7e1f054a9206c60ea0917165d8d9193dc453f34f1", size = 1283704, upload-time = "2026-08-16T22:54:27.279Z" },
{ url = "https://files.pythonhosted.org/packages/46/2a/90cc8d7463929c04786f29600de45f3227c12fa9bed1d5b7ce319b05e1c9/hypothesis-6.165.10-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:10d9a650a4666b0914831f769703d36140ed8039fd19bf9b71f615b8541eccf2", size = 1325077, upload-time = "2026-08-16T22:55:16.561Z" },
{ url = "https://files.pythonhosted.org/packages/82/ac/bc16faba4b42883e3d290bfaceff51e258b63fbbdf789bf9fe88df1ce537/hypothesis-6.165.10-cp311-cp311-win_amd64.whl", hash = "sha256:5671d2b2bf83bd4b6f02e55b32d432506eff5358c82f39b460a849ce19a2666e", size = 674920, upload-time = "2026-08-16T22:55:42.613Z" },
{ url = "https://files.pythonhosted.org/packages/e9/45/cde4f78afe2b9e29caecf38319eedc1deb76aebcacbdd128e03cbb2511c3/hypothesis-6.165.10-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:637445c1593a2a9d1024fda50082f07bb56baedda78d90a25f64b8111727ef94", size = 784835, upload-time = "2026-08-16T22:54:45.429Z" },
{ url = "https://files.pythonhosted.org/packages/7f/81/847f30b81cbfd07607296b3ce43067cf4f80799bd9244167f587de9c8081/hypothesis-6.165.10-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:713f4ce4e82c26b53031f139de959bc9e8b54d3995aa824b89bbdf8229df2a45", size = 776419, upload-time = "2026-08-16T22:55:33.633Z" },
{ url = "https://files.pythonhosted.org/packages/04/66/4c71c5be7a49d84b8c3a9278c1807c4c81181ab5474beb27df9d4c40dc0e/hypothesis-6.165.10-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f9ff356e97e3ab09db07c8b675efa67340103874a0bae7465acb83dad7a35f7f", size = 1106830, upload-time = "2026-08-16T22:55:10.389Z" },
{ url = "https://files.pythonhosted.org/packages/e3/c4/e2cbd2810e79f7a452a8ea9f6c6438ee718ce938d8cc12252cf0b36a81d3/hypothesis-6.165.10-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1a380bc99aa3b035e6a95a2201bf792d4082a04ca75babcc21849c2d0914bb28", size = 1156952, upload-time = "2026-08-16T22:55:53.35Z" },
{ url = "https://files.pythonhosted.org/packages/a8/8b/794ced36864825492ac3712d5acab5a257b4601e6a9dc2ccdd3937198f87/hypothesis-6.165.10-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e9acb2c4d9cb532c3fedea74159f7b923c8c036328c9239b4049e7aa073bdd81", size = 1280780, upload-time = "2026-08-16T22:54:34.983Z" },
{ url = "https://files.pythonhosted.org/packages/5d/2d/550525442cdbcc2daf1f9bdd8ba35bcbde63db7c7a22f2ef137fbb49df2f/hypothesis-6.165.10-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8660572b2d424bf5369ea8990985225f70bd1615b76ecd9c25588a3b9307009f", size = 1324130, upload-time = "2026-08-16T22:55:48.659Z" },
{ url = "https://files.pythonhosted.org/packages/74/59/6caf69dd5fe03499ada94c9cec016bffcc164511c6b93fe680f01209b9ff/hypothesis-6.165.10-cp312-cp312-win_amd64.whl", hash = "sha256:3376f2594763aef14faa519b0fb27cae7ce9eeaab4c69efa07777499110306c9", size = 672337, upload-time = "2026-08-16T22:54:49.11Z" },
{ url = "https://files.pythonhosted.org/packages/b1/fb/c82c5bd92864ffcf319772fedc8c9bf2dbe4ca14baa0fee6e49e67b5ba1c/hypothesis-6.165.10-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:9d77c3be7b429875036ad0f0597c6e5cc6bb17894a4da005e3807de64d2673ad", size = 784726, upload-time = "2026-08-16T22:54:32.371Z" },
{ url = "https://files.pythonhosted.org/packages/0e/b9/3d7acd08506da85557e65147b7f3fca8c47684e33be90bee0acb523920db/hypothesis-6.165.10-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:490c56b830772b0eca3b4b2cecb3741a1ed26b1d7206a279e1525dbf0aa95ee4", size = 776375, upload-time = "2026-08-16T22:55:13.303Z" },
{ url = "https://files.pythonhosted.org/packages/38/6b/922e8b3f9a706dd89d440b9545d2c6231c65e74da1c1fee3ff36c251b9c4/hypothesis-6.165.10-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed68e27b8a61e57a3ccdc7c5a14499e00b54dfe223087204d5d40b3b5ef58b6d", size = 1106763, upload-time = "2026-08-16T22:55:06.129Z" },
{ url = "https://files.pythonhosted.org/packages/01/39/f5b9a5d390d4edd1ad472334493ac442963ebeb4daaa74ff4bdac6ef292f/hypothesis-6.165.10-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6caadcd1afb62630ff5c5ff353626eaa616553a5971295ad6dc2b19ca8a39620", size = 1156778, upload-time = "2026-08-16T22:54:33.824Z" },
{ url = "https://files.pythonhosted.org/packages/b5/5f/5fbe1be4326337fd6acefe2d18ed44007ee1dc1f98fe5b3c0eb22942364d/hypothesis-6.165.10-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9145fe43ebb22e66672967c3fab411793b226ed776e4fe282271bca6ad3c0bb", size = 1280756, upload-time = "2026-08-16T22:55:54.834Z" },
{ url = "https://files.pythonhosted.org/packages/25/c0/cf6f9e1ef632a1a75694eed0db3a02e6fc75c367a363e94acee52f043c64/hypothesis-6.165.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:79900a9920a0b1d3a626c03a90ac6bf7042e78d46906a565b86a0dbe926f1d96", size = 1323889, upload-time = "2026-08-16T22:55:56.567Z" },
{ url = "https://files.pythonhosted.org/packages/cc/cc/662b94880f260b0a88de1fdcf60fc9984f6e2a796da549542adc10a7bc83/hypothesis-6.165.10-cp313-cp313-win_amd64.whl", hash = "sha256:c01dd04044c472e47193b54f68e84e08d6ebf4f29551885aa959b015f7cd9747", size = 672346, upload-time = "2026-08-16T22:56:03.792Z" },
{ url = "https://files.pythonhosted.org/packages/3f/77/55e020c9c576532ff7d20bf8b1dfa052ecbd5ada1949b02f76c44c966f7e/hypothesis-6.165.10-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:9ccac776b2ca93b324806facd526ccb45da0fd035001c899a35b02c44431e209", size = 784833, upload-time = "2026-08-16T22:55:21.255Z" },
{ url = "https://files.pythonhosted.org/packages/4f/f2/01da2adf829cf549eaddcabb8e8072077fb3d26da4275f4c1e89b2c0af74/hypothesis-6.165.10-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e5f95f7b622e4171096d92175dda0a560f0955ade9b8a3a07bdcf151f7359611", size = 776545, upload-time = "2026-08-16T22:56:10.159Z" },
{ url = "https://files.pythonhosted.org/packages/cf/8e/58d4f842895220b793c53fc94a6489705b3665bb4d0ae4d338ce03fdf9fb/hypothesis-6.165.10-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f76d1562643693b8a40066f1f96af795b93fd9bcfc9690a1af2ff4c5867ee29e", size = 1107271, upload-time = "2026-08-16T22:54:50.266Z" },
{ url = "https://files.pythonhosted.org/packages/8f/b8/206468912d2153306bb8a41afdfc59e45b7a73a0495bbe4b9cb4f0e79c1d/hypothesis-6.165.10-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:60cab3ab4ea468d31a33739ffd7e94ec3e37dea891d65a6582ecc8a477175191", size = 1156915, upload-time = "2026-08-16T22:54:25.89Z" },
{ url = "https://files.pythonhosted.org/packages/fb/d3/bf5a22929b70a4cfd3edf69c5642b029b27ddb5cfda48fa295d384b01abb/hypothesis-6.165.10-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:22cf19388f0ff6ced8eb3e49c903d14938e4ed909d93bf28383eef451511e424", size = 1281205, upload-time = "2026-08-16T22:54:44.083Z" },
{ url = "https://files.pythonhosted.org/packages/07/a2/d7b2ba444d36fc84d4779f4431e74dd9b023dc63bcf282199f6e48ad39f4/hypothesis-6.165.10-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:057d0232f1224dcd0b7698902551a4341a7399f90670b036db6c4376715fe889", size = 1324243, upload-time = "2026-08-16T22:55:41.123Z" },
{ url = "https://files.pythonhosted.org/packages/d1/95/afe6b531fd01928c6f63d394ee413fa2338d088b2b44efcc23596b54477e/hypothesis-6.165.10-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:ab0f2e9d7d7d4db257f7cf53de3706c2baf124269571f20ffc2bcd6781f03063", size = 616382, upload-time = "2026-08-16T22:55:18.449Z" },
{ url = "https://files.pythonhosted.org/packages/48/86/9b4fb75f520a028edec50ffc904a94d724180395d71feb6d7a0ce7bb6f00/hypothesis-6.165.10-cp314-cp314-win_amd64.whl", hash = "sha256:d1ea02fa8ab3d33eb1125eade81f7136341eb429152c6dbe2ae6f8bc33b3fbdd", size = 672145, upload-time = "2026-08-16T22:54:24.831Z" },
{ url = "https://files.pythonhosted.org/packages/f9/ba/f7bbaae0c789bab7ddb764d2056ee1a463cc95a8acbccc90d4184e48b242/hypothesis-6.165.10-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:ed1a5891e59472884a03cb9875483e8fc131c80a275c60967f8afc5458a0c8ff", size = 783287, upload-time = "2026-08-16T22:54:23.751Z" },
{ url = "https://files.pythonhosted.org/packages/3a/83/01ef80772b4abd335c49405576dc503cede94fb5da30ba2643a119013aea/hypothesis-6.165.10-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:09772e328a26e50486ac572be34f9887f9aa185efe7ebb16bde4e8f6038db1f4", size = 774991, upload-time = "2026-08-16T22:55:25.987Z" },
{ url = "https://files.pythonhosted.org/packages/a3/0b/f47506241f9d5a5a2efe4c65b6bf4830e9d9576e5d3779007a260699e608/hypothesis-6.165.10-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5cf3b612542ba174c9da4000b59a4f4c81e8d66f87509be85d3a1b71b5c36413", size = 1105499, upload-time = "2026-08-16T22:54:51.864Z" },
{ url = "https://files.pythonhosted.org/packages/84/fe/abb3909b7089835112fbe75bf00d817d733b3a8032759783db0a24ff1e56/hypothesis-6.165.10-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f69ec5be85ef508e206153bed8eafd03f7995dc464356c8bbb279a1e2b7d56f3", size = 1155685, upload-time = "2026-08-16T22:54:30.94Z" },
{ url = "https://files.pythonhosted.org/packages/73/2f/1964738921640184067121ae77414522fc3f0463fc26c6e25a4f3b8e42ca/hypothesis-6.165.10-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:dd207497bb985918409a1bb5db85d1875f74e1269487332113b73d1ee7c77647", size = 1279177, upload-time = "2026-08-16T22:54:40.179Z" },
{ url = "https://files.pythonhosted.org/packages/34/c5/312af8ae038d3af9cf3f7f1021c1abfe31c0d9035e4cf63519e0a7dc983e/hypothesis-6.165.10-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:00de0abdcf8c05c9d0eab735a3c49a276376b55151e6fcb903c2b39a90e5e5c3", size = 1322921, upload-time = "2026-08-16T22:54:42.7Z" },
{ url = "https://files.pythonhosted.org/packages/9e/e7/b0a2fde7570c090a1b914026266a421c751ef10138fffe37fe0ef9e675c0/hypothesis-6.165.10-cp314-cp314t-win_amd64.whl", hash = "sha256:cc2da5aa4edf14743fa9257e5ba3513963999f01211635702479d8e92b8207c8", size = 672147, upload-time = "2026-08-16T22:55:27.527Z" },
{ url = "https://files.pythonhosted.org/packages/47/fd/985aa564d6ffd06483d45a62b40d319df0a703cd8bc1d041de17d102fbaa/hypothesis-6.165.10-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:eeab73050ea58c13dd56e329f594c1dfe32ebd7bb169bbdf4f8ceefbc31ec6b5", size = 782882, upload-time = "2026-08-16T22:55:37.93Z" },
{ url = "https://files.pythonhosted.org/packages/f8/2c/6cc11151e450f72353a490940cd0db704680d07b78dc75dcc9f480e0d0e1/hypothesis-6.165.10-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:4c68e983d0007d014bb01ad4bcbba78bc432c73a1755ff36d5102ceefa18299a", size = 774584, upload-time = "2026-08-16T22:55:51.822Z" },
{ url = "https://files.pythonhosted.org/packages/10/39/ef26fa79c1738dfe9cdb1a3584fb6717d26429ca6c9d011cc4fdf08130c2/hypothesis-6.165.10-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7730d8197086f65d8969a991d6728a1d420a51b19fea06535c896cb43a1e05d0", size = 1104876, upload-time = "2026-08-16T22:54:58.937Z" },
{ url = "https://files.pythonhosted.org/packages/4e/f4/3fcc84e7637f42bf00d987093b9418083ac8db81b87392608a60f4b7c5fd/hypothesis-6.165.10-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7a7980a898a3e6ebe4de1896a0507e3d519edb53fb9b4bda478c9fbeb6514558", size = 1133353, upload-time = "2026-08-16T22:54:28.635Z" },
{ url = "https://files.pythonhosted.org/packages/35/59/21c5c14179c38f8d0de3560e7f1825c083311b3013b63f817d7dc78dfcbd/hypothesis-6.165.10-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b5820d009aedb7ae9cfd32f98b1ab0c0bbd6268379c4fab042218b6b655c63f8", size = 1132300, upload-time = "2026-08-16T22:56:08.539Z" },
{ url = "https://files.pythonhosted.org/packages/14/af/fbb56059961e416b2de7b9dc5352db2e8572bd5ea46892957e4c1e5548ab/hypothesis-6.165.10-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:37a7ac3d34220800e1107871cc391bca1b00439875925d7d821878b8b791f245", size = 1155175, upload-time = "2026-08-16T22:55:19.824Z" },
{ url = "https://files.pythonhosted.org/packages/0f/53/77fb0c2dad445858555429c4e06cf94a59ae8d2407dd6426b5af97c84828/hypothesis-6.165.10-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:dafa7c9dbe3d802f9bcdf261b29c8a70700fb22839947f06e471f62c46b6257f", size = 1109881, upload-time = "2026-08-16T22:55:32.029Z" },
{ url = "https://files.pythonhosted.org/packages/a8/7b/d187f673ff30e6ada640953636f978ffe64a6332f756b64163c2277f8d0c/hypothesis-6.165.10-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:90915635b9648071129b0f72c0673cf8eac9eb84cfd445c5bedef30c714b1ec2", size = 1144963, upload-time = "2026-08-16T22:56:13.428Z" },
{ url = "https://files.pythonhosted.org/packages/e0/60/31d504e364134d60af23e5f6365db0da3cf4a51b3ed3d4836e5a2cff12cf/hypothesis-6.165.10-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:e1bbeb7c506b07ee0422cf9b2f7212fefa4240957f03526d38d27bc6743a0a48", size = 1278684, upload-time = "2026-08-16T22:55:22.971Z" },
{ url = "https://files.pythonhosted.org/packages/ef/e6/89d26834a08c02f8da149e541dd40d7a96f68d9722f43146e69a77436ed7/hypothesis-6.165.10-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:2b36aaffc88625a44f91074c5bbedfdefb9b376c38d1b3c342edcd2e4c8ed16c", size = 1407202, upload-time = "2026-08-16T22:55:14.949Z" },
{ url = "https://files.pythonhosted.org/packages/dc/61/20d1e72246867ea195440092e8bb422c7ddc2f271b87b5b65679d5532719/hypothesis-6.165.10-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:18a3ea838ddea183388f8788750afa8494d79abb5358823be9782585f34445d3", size = 1261395, upload-time = "2026-08-16T22:56:05.448Z" },
{ url = "https://files.pythonhosted.org/packages/2a/9b/ebab6c3c2b90a16abb4119198178652d12aff83cc8ec2cfde5276c69fb1e/hypothesis-6.165.10-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:2a2567b3a03a4a5a7c575c191cfcce321a967df3727803817e75bffbbeaecabe", size = 1279213, upload-time = "2026-08-16T22:55:35.066Z" },
{ url = "https://files.pythonhosted.org/packages/23/78/69b219b524231d36eb20c792e1f01e7cb037e02bd0af1c29f77ed9a969c0/hypothesis-6.165.10-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:8001925fa3dde51cb574e4c9de4c7efe77c4e4d64bd2fd2ef61d5651f9d04f3d", size = 1322367, upload-time = "2026-08-16T22:54:21.279Z" },
{ url = "https://files.pythonhosted.org/packages/55/63/ad5cc153dcc72ae5e7905fb9b3585f3e48ce892a2d6366f90163e867a69d/hypothesis-6.165.10-cp315-abi3.abi3t-win32.whl", hash = "sha256:c6559380469295c4009215fe1cab561301591a3bee2e2fb3f4f96d2273a3affc", size = 666038, upload-time = "2026-08-16T22:56:11.797Z" },
{ url = "https://files.pythonhosted.org/packages/80/32/b62307b73fbc99f0a4381d6f9456df76fbcbb7a27ef7256e26f0376f48ea/hypothesis-6.165.10-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:30797f20ca45e57f526d2df872f63ba453cb4e1091ad542184a7a951af8da79d", size = 671941, upload-time = "2026-08-16T22:55:00.235Z" },
{ url = "https://files.pythonhosted.org/packages/c2/dd/e0f98add0548ef73ea7afac45da1fb8efc854d7f9931db568754d0f963f3/hypothesis-6.165.10-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:c53e9b1c36350df9965ec44d6c0d4e0bbbb38f720dd2b0e1256dc6524d411015", size = 669931, upload-time = "2026-08-16T22:55:50.205Z" },
{ url = "https://files.pythonhosted.org/packages/0b/6a/880d6eeed5c451fb40a66733dadec4a5d498628a4a7f6a8a5f633f4c6dcb/hypothesis-6.165.10-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:34ee6402df6f31274d89119f1561b5f7489c97866afc5b7a3ed3a13d7e762802", size = 784644, upload-time = "2026-08-16T22:54:20.127Z" },
{ url = "https://files.pythonhosted.org/packages/27/e0/9e942bd3c3cf5ea0d5c0fd0905893bbfb6cefb7284c70fcc8033f8fdec38/hypothesis-6.165.10-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:277f41801e88dad2eba082f91a75632b7584ff64044ba2cf9dadf511b0d19cd0", size = 780515, upload-time = "2026-08-16T22:55:04.676Z" },
{ url = "https://files.pythonhosted.org/packages/19/32/f11a618415dc5fa9cdde41fea56c489f0814759527ae1ecd11a75a4558b9/hypothesis-6.165.10-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:72df95fb1db41755b155c5f02106e0036a339250555c8d351d488704fd112cf9", size = 1109374, upload-time = "2026-08-16T22:56:00.241Z" },
{ url = "https://files.pythonhosted.org/packages/5e/6f/db49b719842297c2b71e0d81e5b8967d31215fb7389421abcb465ce7ed3f/hypothesis-6.165.10-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e20a02775eb3cf0ffb4f0219b6d7c1f240336663d4e5d7028675ec247c790c4", size = 1159092, upload-time = "2026-08-16T22:55:58.57Z" },
{ url = "https://files.pythonhosted.org/packages/b2/2a/bf0bae84ba1cb3923d295973f1fe38ee867eaf90119e0d559116083be300/hypothesis-6.165.10-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:1ec53f08732e3cfd0342cbbd75dbd1b193c8f19390660466e536a748bb81f757", size = 676045, upload-time = "2026-08-16T22:55:45.514Z" },
]
[[package]]
name = "idna"
version = "3.15"
@ -4430,6 +4522,7 @@ dev = [
{ name = "diff-cover" },
{ name = "fakeredis" },
{ name = "fastapi-offline" },
{ name = "hypothesis" },
{ name = "keyring" },
{ name = "langfuse" },
{ name = "openapi-core" },
@ -4449,6 +4542,7 @@ dev = [
{ name = "pytest-rerunfailures" },
{ name = "pytest-timeout" },
{ name = "pytest-xdist" },
{ name = "reportlab" },
{ name = "requests-mock" },
{ name = "responses" },
{ name = "respx" },
@ -4615,6 +4709,7 @@ dev = [
{ name = "diff-cover", specifier = "==9.7.2" },
{ name = "fakeredis", specifier = "==2.34.1" },
{ name = "fastapi-offline", specifier = "==1.7.6" },
{ name = "hypothesis", specifier = "==6.165.10" },
{ name = "keyring", specifier = "==25.7.0" },
{ name = "langfuse", specifier = "==2.59.7" },
{ name = "openapi-core", specifier = "==0.22.0" },
@ -4634,6 +4729,7 @@ dev = [
{ name = "pytest-rerunfailures", specifier = "==15.1" },
{ name = "pytest-timeout", specifier = "==2.4.0" },
{ name = "pytest-xdist", specifier = "==3.8.0" },
{ name = "reportlab", specifier = "==5.0.1" },
{ name = "requests-mock", specifier = "==1.12.1" },
{ name = "responses", specifier = "==0.26.0" },
{ name = "respx", specifier = "==0.22.0" },
@ -8189,6 +8285,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/da/80/35b4c33c804a165a7f55289afda3ea9e3eb6d15800341a2d66455c0f1f30/regex-2026.5.9-cp314-cp314t-win_arm64.whl", hash = "sha256:5e41809d2683fcde7d5a8c87a6567ba1fb1ce0de9f31bff578de00a4b2d76daa", size = 275713, upload-time = "2026-05-09T23:15:16.98Z" },
]
[[package]]
name = "reportlab"
version = "5.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "charset-normalizer" },
{ name = "pillow" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4a/51/dbe28534ae12c852f61be91f039f343305fd1f34f1c66b8de75afae7a525/reportlab-5.0.1.tar.gz", hash = "sha256:ebd13154be1c8515e665de70bd2d303ae9ddc3ef47e44afd5116441ca0283a26", size = 3945711, upload-time = "2026-08-20T13:48:16.461Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/db/cb/dacbc268cb68d0428ea2cbd85266195a9ab3e677449589ddae59bd7542ac/reportlab-5.0.1-py3-none-any.whl", hash = "sha256:1c36e6bb0e71780c72331eba60da7f602e8d4389a8723825af71342e49d791e8", size = 1957258, upload-time = "2026-08-20T13:48:14.026Z" },
]
[[package]]
name = "requests"
version = "2.34.0"