diff --git a/.github/workflows/test-unit.yml b/.github/workflows/test-unit.yml index 6bc44995804..33245ec5b5f 100644 --- a/.github/workflows/test-unit.yml +++ b/.github/workflows/test-unit.yml @@ -96,7 +96,6 @@ jobs: - shard: misc artifact-name: misc test-path: >- - tests/sdk_function_trace tests/test_litellm/batches tests/test_litellm/secret_managers tests/test_litellm/a2a_protocol diff --git a/litellm-rust/Cargo.lock b/litellm-rust/Cargo.lock index b3dac5ca935..72688d5248e 100644 --- a/litellm-rust/Cargo.lock +++ b/litellm-rust/Cargo.lock @@ -1415,6 +1415,7 @@ dependencies = [ "litellm-core", "pyo3", "reqwest", + "rstest", "serde", "serde_json", "sha2 0.10.9", @@ -1435,14 +1436,17 @@ dependencies = [ "aws-sigv4", "aws-smithy-runtime-api", "aws-types", + "base64", "rand 0.8.7", "reqwest", + "rstest", "serde", "serde_json", "sha2 0.10.9", "thiserror 2.0.19", "tokio", "tracing", + "tracing-subscriber", ] [[package]] @@ -1461,7 +1465,6 @@ dependencies = [ "tokio", "tokio-tungstenite", "tracing", - "tracing-subscriber", ] [[package]] @@ -1511,6 +1514,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "mio" version = "1.2.2" @@ -1969,6 +1982,7 @@ dependencies = [ "hyper-util", "js-sys", "log", + "mime_guess", "percent-encoding", "pin-project-lite", "quinn", @@ -2736,6 +2750,12 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.24" diff --git a/litellm-rust/Cargo.toml b/litellm-rust/Cargo.toml index 643ad985251..62f62872dd7 100644 --- a/litellm-rust/Cargo.toml +++ b/litellm-rust/Cargo.toml @@ -24,7 +24,7 @@ pyo3 = "0.29.2" pyo3-async-runtimes = { version = "0.29.0", features = ["tokio-runtime"] } pythonize = "0.29.0" rand = "0.8" -reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls", "http2", "stream"] } +reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "multipart", "rustls-tls", "http2", "stream"] } rstest = "0.26.1" serde = { version = "1.0", features = ["derive"] } serde_json = { version = "1.0", features = ["float_roundtrip"] } diff --git a/litellm-rust/crates/ai-gateway/Cargo.toml b/litellm-rust/crates/ai-gateway/Cargo.toml index e3dbdf24ce6..73b68e1a671 100644 --- a/litellm-rust/crates/ai-gateway/Cargo.toml +++ b/litellm-rust/crates/ai-gateway/Cargo.toml @@ -32,6 +32,7 @@ subtle = { workspace = true, optional = true } # SHA-256 hash_token) so the plaintext credential never enters a log payload. sha2 = { workspace = true, optional = true } pyo3 = { workspace = true, features = ["auto-initialize"], optional = true } +tower = { version = "0.5.3", features = ["util"], optional = true } [features] default = [] @@ -39,7 +40,9 @@ server = ["dep:axum", "dep:subtle", "dep:sha2"] # Build the gateway's config from the proxy YAML via an embedded Python # interpreter (links libpython; requires `litellm` importable at runtime). python-config = ["dep:pyo3"] +trace-parity = ["server", "dep:tower", "litellm-core/observability"] [dev-dependencies] futures-channel = "0.3" +rstest.workspace = true tower = { version = "0.5.3", features = ["util"] } diff --git a/litellm-rust/crates/ai-gateway/src/lib.rs b/litellm-rust/crates/ai-gateway/src/lib.rs index 057db6457c4..a2950748afc 100644 --- a/litellm-rust/crates/ai-gateway/src/lib.rs +++ b/litellm-rust/crates/ai-gateway/src/lib.rs @@ -28,6 +28,8 @@ pub mod auth; pub mod routes; #[cfg(feature = "server")] pub mod state; +#[cfg(feature = "trace-parity")] +pub mod trace_parity; mod constants; pub mod integrations; diff --git a/litellm-rust/crates/ai-gateway/src/ocr/common_utils.rs b/litellm-rust/crates/ai-gateway/src/ocr/common_utils.rs index c1fb328893b..064b18a1fc0 100644 --- a/litellm-rust/crates/ai-gateway/src/ocr/common_utils.rs +++ b/litellm-rust/crates/ai-gateway/src/ocr/common_utils.rs @@ -12,6 +12,7 @@ use litellm_core::providers::azure_ai::ocr::transformation::{ AZURE_AI_OCR_CONFIG, AZURE_DOCUMENT_INTELLIGENCE_OCR_CONFIG, }; use litellm_core::providers::mistral::ocr::transformation::MISTRAL_OCR_CONFIG; +use litellm_core::providers::reducto::ocr::transformation as reducto; use litellm_core::providers::vertex_ai::ocr::transformation as vertex_ai; use litellm_core::providers::vertex_ai::ocr::transformation::{ VERTEX_AI_DEEPSEEK_OCR_CONFIG, VERTEX_AI_OCR_CONFIG, @@ -39,6 +40,7 @@ pub(super) fn ocr_provider_config( ) -> Option<&'static dyn OcrProviderConfig> { match provider { "mistral" => Some(&MISTRAL_OCR_CONFIG), + "reducto" => reducto::config_for_model(model), "azure_ai" if is_azure_document_intelligence_model(model) => { Some(&AZURE_DOCUMENT_INTELLIGENCE_OCR_CONFIG) } @@ -334,6 +336,7 @@ fn operation_status(response_json: &Value) -> Result<&str, Error> { } } +#[tracing::instrument(target = "litellm::function_trace", level = "trace", skip_all)] pub(super) async fn poll_document_intelligence( operation_url: &str, original_url: &str, diff --git a/litellm-rust/crates/ai-gateway/src/ocr/handler.rs b/litellm-rust/crates/ai-gateway/src/ocr/handler.rs index 856d9571201..6c6e12724cd 100644 --- a/litellm-rust/crates/ai-gateway/src/ocr/handler.rs +++ b/litellm-rust/crates/ai-gateway/src/ocr/handler.rs @@ -50,7 +50,11 @@ pub(crate) async fn execute_ocr_provider_call( .await?; return Ok(request .config - .transform_ocr_response(&request.model, response_json)? + .transform_ocr_response_with_params( + &request.model, + response_json, + &request.optional_params, + )? .into_json()); } @@ -71,6 +75,10 @@ pub(crate) async fn execute_ocr_provider_call( Ok(request .config - .transform_ocr_response(&request.model, response_json)? + .transform_ocr_response_with_params( + &request.model, + response_json, + &request.optional_params, + )? .into_json()) } diff --git a/litellm-rust/crates/ai-gateway/src/ocr/hooks.rs b/litellm-rust/crates/ai-gateway/src/ocr/hooks.rs index f8c4f8fe8c5..446b323db3a 100644 --- a/litellm-rust/crates/ai-gateway/src/ocr/hooks.rs +++ b/litellm-rust/crates/ai-gateway/src/ocr/hooks.rs @@ -1,11 +1,15 @@ use litellm_core::call_lifecycle::{CallLifecycleContext, CallLifecycleHooks, CallLifecycleTiming}; use litellm_core::error::Error; +use litellm_core::providers::reducto::ocr::transformation::{ + build_upload_request, extract_document_source, extract_upload_file_id, +}; use serde_json::{Map, Value, json}; use std::future::Future; use std::pin::Pin; -use super::common_utils::{convert_document_url_to_data_uri, string_headers}; +use super::common_utils::{convert_document_url_to_data_uri, string_headers, truncate_error_body}; use super::types::{PreparedOcrRequest, ProviderOcrRequest}; +use crate::client::http_client; use crate::integrations::custom_guardrail::{ CustomGuardrailRunner, GuardrailContext, GuardrailError, GuardrailRequest, }; @@ -89,22 +93,39 @@ impl OcrLifecycleHooks { )?; let model = request.model.clone(); let custom_llm_provider = request.custom_llm_provider.clone(); - let document = if config.requires_data_uri_document() { + let is_reducto = custom_llm_provider == "reducto"; + let document = if is_reducto { + let guarded_document = self + .run_during_call_guardrails(&model, &custom_llm_provider, &url, request.document) + .await?; + upload_reducto_document( + &guarded_document, + request.api_base.as_deref(), + request.timeout, + &upstream_headers, + ) + .await? + } else if config.requires_data_uri_document() { convert_document_url_to_data_uri(request.document).await? } else { request.document }; + let optional_params = request.optional_params; let body = config - .transform_ocr_request(&request.model, document, request.optional_params)? + .transform_ocr_request(&request.model, document, optional_params.clone())? .data; - let body = self - .run_during_call_guardrails(&model, &custom_llm_provider, &url, body) - .await?; + let body = if is_reducto { + body + } else { + self.run_during_call_guardrails(&model, &custom_llm_provider, &url, body) + .await? + }; Ok(ProviderOcrRequest { model, config, url, body, + optional_params, upstream_headers, timeout: request.timeout, }) @@ -165,6 +186,63 @@ impl OcrLifecycleHooks { } } +async fn upload_reducto_document( + document: &Value, + api_base: Option<&str>, + timeout: Option, + upstream_headers: &[(String, String)], +) -> Result { + let source = extract_document_source(document)?; + let Some(authorization) = upstream_headers + .iter() + .find(|(name, _)| name.eq_ignore_ascii_case("authorization")) + .map(|(_, value)| value.as_str()) + else { + return Err(Error::Auth( + "Reducto upload requires an Authorization header".to_string(), + )); + }; + let Some(upload) = build_upload_request(source, authorization, api_base) else { + return Ok(document.clone()); + }; + let part = reqwest::multipart::Part::bytes(upload.bytes) + .file_name(upload.file_name) + .mime_str(&upload.mime_type) + .map_err(|error| Error::InvalidRequest(error.to_string()))?; + let form = reqwest::multipart::Form::new().part("file", part); + let mut request_builder = http_client().post(upload.url).multipart(form); + for (name, value) in upstream_headers { + if !name.eq_ignore_ascii_case("content-type") + && !name.eq_ignore_ascii_case("content-length") + { + request_builder = request_builder.header(name, value); + } + } + if let Some(timeout) = timeout { + request_builder = request_builder.timeout(timeout); + } + let response = request_builder + .send() + .await + .map_err(|error| Error::Network(error.to_string()))?; + let status = response.status(); + let body = response + .text() + .await + .map_err(|error| Error::Network(error.to_string()))?; + if !status.is_success() { + return Err(Error::Http { + status: status.as_u16(), + body: truncate_error_body(&body), + }); + } + let response_json: Value = serde_json::from_str(&body).map_err(|error| { + Error::InvalidResponse(format!("invalid Reducto upload response JSON: {error}")) + })?; + let file_id = extract_upload_file_id(&response_json)?; + Ok(json!({"type": "document_url", "document_url": file_id})) +} + impl CallLifecycleHooks for OcrLifecycleHooks { type PreCallFuture<'a> = OcrFuture<'a, PreparedOcrRequest>; type DuringCallFuture<'a> = OcrFuture<'a, PreparedOcrRequest>; diff --git a/litellm-rust/crates/ai-gateway/src/ocr/prepare.rs b/litellm-rust/crates/ai-gateway/src/ocr/prepare.rs index fedacc62760..ab70d9a6891 100644 --- a/litellm-rust/crates/ai-gateway/src/ocr/prepare.rs +++ b/litellm-rust/crates/ai-gateway/src/ocr/prepare.rs @@ -2,6 +2,7 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; use litellm_core::routing_utils::provider::{CustomLlmProvider, get_custom_llm_provider}; +use serde_json::{Map, Value}; use super::common_utils::ocr_provider_config; use super::hooks::OcrLifecycleHooks; @@ -28,17 +29,33 @@ pub(crate) fn prepare_ocr_call(request: OcrRequest<'_>) -> PreparedOcrCall { let model = provider_info.model.to_string(); let custom_llm_provider = provider_info.custom_llm_provider.to_string(); let config = ocr_provider_config(&custom_llm_provider, &model) - .ok_or_else(|| litellm_core::Error::InvalidProvider(custom_llm_provider.clone())); + .ok_or_else(|| litellm_core::Error::InvalidProvider(custom_llm_provider.clone())) + .and_then(|config| { + validate_request_format(config, &request.optional_params, &custom_llm_provider)?; + Ok(config) + }); let optional_params = match &config { Ok(config) => { let supported = config.supported_ocr_params(); - config.map_ocr_params( + let mut mapped = config.map_ocr_params( &request .optional_params - .into_iter() + .iter() .filter(|(name, _)| supported.contains(&name.as_str())) + .map(|(name, value)| (name.clone(), value.clone())) .collect(), - ) + ); + for name in [ + "vertex_project", + "vertex_ai_project", + "vertex_location", + "vertex_ai_location", + ] { + if let Some(value) = request.optional_params.get(name) { + mapped.insert(name.to_string(), value.clone()); + } + } + mapped } Err(_) => request.optional_params, }; @@ -64,6 +81,26 @@ pub(crate) fn prepare_ocr_call(request: OcrRequest<'_>) -> PreparedOcrCall { } } +fn validate_request_format( + config: &'static dyn litellm_core::ocr::transformation::OcrProviderConfig, + optional_params: &Map, + provider: &str, +) -> Result<(), litellm_core::Error> { + let Some(format) = optional_params.get("req_format") else { + return Ok(()); + }; + match format.as_str() { + Some("litellm") => Ok(()), + Some("native") if config.supported_ocr_params().contains(&"req_format") => Ok(()), + Some("native") => Err(litellm_core::Error::InvalidRequest(format!( + "`req_format=native` is not supported for provider {provider}" + ))), + _ => Err(litellm_core::Error::InvalidRequest(format!( + "Invalid `req_format`: {format}. Expected `litellm` or `native`" + ))), + } +} + fn new_ocr_call_id() -> String { static COUNTER: AtomicU64 = AtomicU64::new(1); let sequence = COUNTER.fetch_add(1, Ordering::Relaxed); diff --git a/litellm-rust/crates/ai-gateway/src/ocr/types.rs b/litellm-rust/crates/ai-gateway/src/ocr/types.rs index 95e551d79ca..75a8e61ddbf 100644 --- a/litellm-rust/crates/ai-gateway/src/ocr/types.rs +++ b/litellm-rust/crates/ai-gateway/src/ocr/types.rs @@ -53,6 +53,7 @@ pub(crate) struct ProviderOcrRequest { pub(crate) config: &'static dyn OcrProviderConfig, pub(crate) url: String, pub(crate) body: Value, + pub(crate) optional_params: Map, pub(crate) upstream_headers: Vec<(String, String)>, pub(crate) timeout: Option, } diff --git a/litellm-rust/crates/ai-gateway/src/routes/messages/mod.rs b/litellm-rust/crates/ai-gateway/src/routes/messages/mod.rs index e9f8c477f36..bb9f3851a77 100644 --- a/litellm-rust/crates/ai-gateway/src/routes/messages/mod.rs +++ b/litellm-rust/crates/ai-gateway/src/routes/messages/mod.rs @@ -21,6 +21,12 @@ pub fn router() -> Router { Router::new().route(MESSAGES_ROUTE_PATH, post(handle)) } +#[tracing::instrument( + name = "messages_gateway_route", + target = "litellm::function_trace", + level = "trace", + skip_all +)] async fn handle( _auth: RequireMasterKey, State(state): State, diff --git a/litellm-rust/crates/ai-gateway/src/routes/messages/service.rs b/litellm-rust/crates/ai-gateway/src/routes/messages/service.rs index 4fd29db05d6..5434719987b 100644 --- a/litellm-rust/crates/ai-gateway/src/routes/messages/service.rs +++ b/litellm-rust/crates/ai-gateway/src/routes/messages/service.rs @@ -12,6 +12,12 @@ pub(crate) enum MessagesResponse { Stream(reqwest::Response), } +#[tracing::instrument( + name = "messages_gateway_service", + target = "litellm::function_trace", + level = "trace", + skip_all +)] pub async fn run( router: &Arc, body: Value, diff --git a/litellm-rust/crates/ai-gateway/src/trace_parity.rs b/litellm-rust/crates/ai-gateway/src/trace_parity.rs new file mode 100644 index 00000000000..614852c541d --- /dev/null +++ b/litellm-rust/crates/ai-gateway/src/trace_parity.rs @@ -0,0 +1,65 @@ +//! Harness-only in-process adapters. Never mounted as production routes. + +use std::sync::Arc; + +use axum::body::{Body, to_bytes}; +use axum::http::header::{AUTHORIZATION, CONTENT_TYPE}; +use axum::http::{Request, StatusCode}; +use litellm_core::Error; +use litellm_core::router::{Deployment, LiteLLMParams, Router as ModelRouter}; +use serde::Serialize; +use serde_json::Value; +use tower::ServiceExt; + +use crate::io::realtime_pool::RealtimePool; +use crate::routes; +use crate::state::AppState; + +#[derive(Debug, Serialize)] +pub struct GatewayResponse { + pub status: u16, + pub body: Value, +} + +pub async fn messages_request( + model_alias: String, + provider_model: String, + api_base: String, + body: Value, +) -> Result { + let state = AppState { + router: Arc::new(ModelRouter::new(vec![Deployment { + model_name: model_alias, + litellm_params: LiteLLMParams { + model: provider_model, + api_key: Some("trace-provider-key".to_string()), + api_base: Some(api_base), + }, + }])), + master_key: Some(Arc::from("trace-master-key")), + loggers: Arc::new(Vec::new()), + realtime_pool: RealtimePool::disabled(), + }; + let request = Request::builder() + .method("POST") + .uri("/v1/messages") + .header(AUTHORIZATION, "Bearer trace-master-key") + .header(CONTENT_TYPE, "application/json") + .body(Body::from(body.to_string())) + .map_err(|error| Error::InvalidRequest(error.to_string()))?; + let response = routes::app(state) + .oneshot(request) + .await + .map_err(|error| match error {})?; + let status: StatusCode = response.status(); + let bytes = to_bytes(response.into_body(), usize::MAX) + .await + .map_err(|error| Error::InvalidResponse(error.to_string()))?; + let body = serde_json::from_slice(&bytes).map_err(|error| { + Error::InvalidResponse(format!("gateway returned invalid JSON: {error}")) + })?; + Ok(GatewayResponse { + status: status.as_u16(), + body, + }) +} diff --git a/litellm-rust/crates/core/Cargo.toml b/litellm-rust/crates/core/Cargo.toml index 389dbd49505..c0de7ff3977 100644 --- a/litellm-rust/crates/core/Cargo.toml +++ b/litellm-rust/crates/core/Cargo.toml @@ -6,12 +6,14 @@ license.workspace = true repository.workspace = true [dependencies] +base64.workspace = true rand.workspace = true reqwest.workspace = true serde.workspace = true serde_json.workspace = true thiserror.workspace = true tracing.workspace = true +tracing-subscriber = { workspace = true, optional = true } sha2.workspace = true aws-config = { version = "1.9.0", default-features = false, features = ["rustls", "rt-tokio"], optional = true } aws-credential-types = { version = "1.3.0", features = ["hardcoded-credentials"], optional = true } @@ -30,6 +32,9 @@ bedrock-auth = [ "dep:aws-types", "dep:aws-smithy-runtime-api", ] +observability = ["dep:tracing-subscriber"] [dev-dependencies] +rstest.workspace = true tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } +tracing-subscriber.workspace = true diff --git a/litellm-rust/crates/core/src/constants.rs b/litellm-rust/crates/core/src/constants.rs index a73961060eb..fc81f4fa029 100644 --- a/litellm-rust/crates/core/src/constants.rs +++ b/litellm-rust/crates/core/src/constants.rs @@ -41,3 +41,5 @@ pub const CHAT_COMPLETION_OBJECT: &str = "chat.completion"; /// `litellm/litellm_core_utils/prompt_templates/factory.py`. pub const EMPTY_TEXT_PLACEHOLDER: &str = "[System: Empty message content sanitised to satisfy protocol]"; + +pub const FUNCTION_TRACE_TARGET: &str = "litellm::function_trace"; diff --git a/litellm-rust/crates/core/src/lib.rs b/litellm-rust/crates/core/src/lib.rs index 0e18d24e5d8..b93e084f57e 100644 --- a/litellm-rust/crates/core/src/lib.rs +++ b/litellm-rust/crates/core/src/lib.rs @@ -6,6 +6,8 @@ pub mod constants; pub mod error; pub mod http_utils; pub mod messages; +#[cfg(any(feature = "observability", test))] +pub mod observability; pub mod ocr; pub mod providers; pub mod realtime; diff --git a/litellm-rust/crates/core/src/observability/function_trace.rs b/litellm-rust/crates/core/src/observability/function_trace.rs new file mode 100644 index 00000000000..2031e35901c --- /dev/null +++ b/litellm-rust/crates/core/src/observability/function_trace.rs @@ -0,0 +1,215 @@ +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use serde::Serialize; +use tracing::span::{Attributes, Id}; +use tracing::{Dispatch, Subscriber}; +use tracing_subscriber::layer::Context; +use tracing_subscriber::prelude::*; +use tracing_subscriber::registry::LookupSpan; +use tracing_subscriber::{Layer, Registry}; + +use super::function_trace_filter; + +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct FunctionTraceEvent { + pub id: usize, + pub parent_id: Option, + pub function: &'static str, + pub module_path: Option<&'static str>, + pub file: Option<&'static str>, + pub line: Option, +} + +#[derive(Clone, Default)] +pub struct FunctionTrace { + events: Arc>>, + span_events: Arc>>, +} + +impl FunctionTrace { + pub fn dispatcher(&self) -> Dispatch { + Dispatch::new( + Registry::default().with( + FunctionTraceLayer { + trace: self.clone(), + } + .with_filter(function_trace_filter()), + ), + ) + } + + pub fn events(&self) -> Vec { + self.events + .lock() + .unwrap_or_else(|error| error.into_inner()) + .clone() + } +} + +struct FunctionTraceLayer { + trace: FunctionTrace, +} + +impl Layer for FunctionTraceLayer +where + S: Subscriber + for<'lookup> LookupSpan<'lookup>, +{ + fn on_new_span(&self, attributes: &Attributes<'_>, id: &Id, context: Context<'_, S>) { + let parent_id = context.span(id).and_then(|span| { + let span_events = self + .trace + .span_events + .lock() + .unwrap_or_else(|error| error.into_inner()); + span.scope() + .skip(1) + .find_map(|ancestor| span_events.get(&ancestor.id()).copied()) + }); + let mut events = self + .trace + .events + .lock() + .unwrap_or_else(|error| error.into_inner()); + let event_id = events.len(); + events.push(FunctionTraceEvent { + id: event_id, + parent_id, + function: attributes.metadata().name(), + module_path: attributes.metadata().module_path(), + file: attributes.metadata().file(), + line: attributes.metadata().line(), + }); + self.trace + .span_events + .lock() + .unwrap_or_else(|error| error.into_inner()) + .insert(id.clone(), event_id); + } +} + +#[cfg(test)] +mod tests { + use crate::constants::FUNCTION_TRACE_TARGET; + + use super::*; + + fn event( + id: usize, + parent_id: Option, + function: &'static str, + ) -> (usize, Option, &'static str) { + (id, parent_id, function) + } + + fn structural_events( + events: &[FunctionTraceEvent], + ) -> Vec<(usize, Option, &'static str)> { + events + .iter() + .map(|event| (event.id, event.parent_id, event.function)) + .collect() + } + + #[tracing::instrument(target = "litellm::function_trace", level = "trace", skip_all)] + async fn outer() { + tokio::task::yield_now().await; + inner().await; + } + + #[tracing::instrument(target = "litellm::function_trace", level = "trace", skip_all)] + async fn inner() { + tokio::task::yield_now().await; + } + + #[tracing::instrument(target = "litellm::function_trace", level = "trace", skip_all)] + async fn concurrent_parent() { + tokio::join!(inner(), inner()); + } + + #[tokio::test] + async fn concurrent_futures_keep_separate_traces_across_yields() { + use tracing::instrument::WithSubscriber; + + let first = FunctionTrace::default(); + let second = FunctionTrace::default(); + let outside = FunctionTrace::default(); + + async { + tokio::join!( + outer().with_subscriber(first.dispatcher()), + inner().with_subscriber(second.dispatcher()), + ); + inner().await; + } + .with_subscriber(outside.dispatcher()) + .await; + + assert_eq!( + structural_events(&first.events()), + vec![event(0, None, "outer"), event(1, Some(0), "inner")], + ); + assert_eq!( + structural_events(&second.events()), + vec![event(0, None, "inner")], + ); + assert_eq!( + structural_events(&outside.events()), + vec![event(0, None, "inner")], + ); + } + + #[tokio::test] + async fn concurrent_siblings_keep_the_same_parent() { + use tracing::instrument::WithSubscriber; + + let trace = FunctionTrace::default(); + concurrent_parent() + .with_subscriber(trace.dispatcher()) + .await; + + assert_eq!( + structural_events(&trace.events()), + vec![ + event(0, None, "concurrent_parent"), + event(1, Some(0), "inner"), + event(2, Some(0), "inner"), + ] + ); + } + + #[test] + fn records_matching_spans_in_creation_order() { + let trace = FunctionTrace::default(); + let dispatch = trace.dispatcher(); + + tracing::dispatcher::with_default(&dispatch, || { + let _ignored = tracing::trace_span!(target: "other", "ignored"); + let _first = tracing::trace_span!(target: FUNCTION_TRACE_TARGET, "same_name"); + let _wrong_level = tracing::debug_span!(target: FUNCTION_TRACE_TARGET, "wrong_level"); + let _second = tracing::trace_span!(target: FUNCTION_TRACE_TARGET, "same_name"); + }); + + assert_eq!( + structural_events(&trace.events()), + vec![event(0, None, "same_name"), event(1, None, "same_name")] + ); + } + + #[test] + fn records_matching_span_nesting_depth() { + let trace = FunctionTrace::default(); + let dispatch = trace.dispatcher(); + + tracing::dispatcher::with_default(&dispatch, || { + let outer = tracing::trace_span!(target: FUNCTION_TRACE_TARGET, "outer"); + let _outer_guard = outer.enter(); + let _inner = tracing::trace_span!(target: FUNCTION_TRACE_TARGET, "inner"); + }); + + assert_eq!( + structural_events(&trace.events()), + vec![event(0, None, "outer"), event(1, Some(0), "inner")] + ); + } +} diff --git a/litellm-rust/crates/core/src/observability/mod.rs b/litellm-rust/crates/core/src/observability/mod.rs new file mode 100644 index 00000000000..3f9da8e2bb4 --- /dev/null +++ b/litellm-rust/crates/core/src/observability/mod.rs @@ -0,0 +1,59 @@ +use tracing::span::Id; +use tracing::{Level, Metadata, Subscriber}; +use tracing_subscriber::filter::{FilterFn, LevelFilter, filter_fn}; +use tracing_subscriber::layer::Context; +use tracing_subscriber::registry::LookupSpan; + +use crate::constants::FUNCTION_TRACE_TARGET; + +pub mod function_trace; + +pub use function_trace::{FunctionTrace, FunctionTraceEvent}; + +pub fn function_trace_filter() -> FilterFn) -> bool> { + filter_fn(|metadata| { + metadata.is_span() + && metadata.target() == FUNCTION_TRACE_TARGET + && *metadata.level() == Level::TRACE + }) + .with_max_level_hint(LevelFilter::TRACE) +} + +pub fn span_depth(context: &Context<'_, S>, id: &Id) -> usize +where + S: Subscriber + for<'lookup> LookupSpan<'lookup>, +{ + context + .span(id) + .map(|span| span.scope().skip(1).count()) + .unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use tracing::instrument::WithSubscriber; + + use super::*; + + #[tracing::instrument(target = "litellm::function_trace", level = "trace", skip_all)] + async fn instrumented_with_literal_target() {} + + #[tokio::test] + async fn literal_instrument_target_matches_filter_constant() { + assert_eq!(FUNCTION_TRACE_TARGET, "litellm::function_trace"); + + let trace = FunctionTrace::default(); + instrumented_with_literal_target() + .with_subscriber(trace.dispatcher()) + .await; + + let events = trace.events(); + assert_eq!(events.len(), 1); + assert_eq!(events[0].id, 0); + assert_eq!(events[0].parent_id, None); + assert_eq!(events[0].function, "instrumented_with_literal_target"); + assert_eq!(events[0].module_path, Some(module_path!())); + assert_eq!(events[0].file, Some(file!())); + assert!(events[0].line.is_some()); + } +} diff --git a/litellm-rust/crates/core/src/ocr/transformation.rs b/litellm-rust/crates/core/src/ocr/transformation.rs index ad484c8f968..62299faf9ed 100644 --- a/litellm-rust/crates/core/src/ocr/transformation.rs +++ b/litellm-rust/crates/core/src/ocr/transformation.rs @@ -51,6 +51,15 @@ pub trait OcrProviderConfig: Sync { response_json: Value, ) -> Result; + fn transform_ocr_response_with_params( + &self, + model: &str, + response_json: Value, + _optional_params: &Map, + ) -> Result { + self.transform_ocr_response(model, response_json) + } + fn complete_url( &self, api_base: Option<&str>, diff --git a/litellm-rust/crates/core/src/ocr/types.rs b/litellm-rust/crates/core/src/ocr/types.rs index 1a72b8f1d66..71cdb232a87 100644 --- a/litellm-rust/crates/core/src/ocr/types.rs +++ b/litellm-rust/crates/core/src/ocr/types.rs @@ -1,5 +1,5 @@ use serde::{Deserialize, Serialize}; -use serde_json::Value; +use serde_json::{Map, Value}; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct OcrRequestData { @@ -14,16 +14,25 @@ pub struct OcrResponseData { pub document_annotation: Option, pub usage_info: Option, pub object: String, + pub extra_fields: Map, + pub provider_native_response: Option, } impl OcrResponseData { pub fn into_json(self) -> Value { - serde_json::json!({ + let mut response = serde_json::json!({ "pages": self.pages, "model": self.model, "document_annotation": self.document_annotation, "usage_info": self.usage_info, "object": self.object, - }) + }); + if let Value::Object(object) = &mut response { + object.extend(self.extra_fields); + if let Some(native_response) = self.provider_native_response { + object.insert("provider_native_response".to_string(), native_response); + } + } + response } } diff --git a/litellm-rust/crates/core/src/providers/azure_ai/ocr/transformation.rs b/litellm-rust/crates/core/src/providers/azure_ai/ocr/transformation.rs index 641a019476e..4ee856005b4 100644 --- a/litellm-rust/crates/core/src/providers/azure_ai/ocr/transformation.rs +++ b/litellm-rust/crates/core/src/providers/azure_ai/ocr/transformation.rs @@ -14,7 +14,8 @@ 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", "features"]; +const AZURE_DOCUMENT_INTELLIGENCE_SUPPORTED_OCR_PARAMS: &[&str] = + &["pages", "features", "req_format"]; pub struct AzureAiOcrConfig; pub struct AzureDocumentIntelligenceOcrConfig; @@ -98,9 +99,76 @@ pub fn resolve_document_intelligence_endpoint( ) } -fn encode_model_id(model: &str) -> String { +fn prepend_auth_header( + headers: Vec<(String, String)>, + name: &str, + value: String, +) -> Vec<(String, String)> { + std::iter::once((name.to_string(), value)) + .chain(headers) + .collect() +} + +pub fn validate_azure_ai_environment( + headers: Vec<(String, String)>, + api_key: Option<&str>, + azure_ad_token: Option<&str>, + env_lookup: &dyn Fn(&str) -> Option, +) -> Result, Error> { + if crate::http_utils::has_header(&headers, "Authorization") + || crate::http_utils::has_header(&headers, "Api-Key") + { + return Ok(headers); + } + if let Ok(api_key) = resolve_azure_ai_api_key(api_key, env_lookup) { + return Ok(prepend_auth_header(headers, "Api-Key", api_key)); + } + non_empty(azure_ad_token) + .map(|token| prepend_auth_header(headers, "Authorization", format!("Bearer {token}"))) + .ok_or_else(|| { + Error::Auth( + "Missing Azure AI credentials - set AZURE_AI_API_KEY or provide azure_ad_token" + .to_string(), + ) + }) +} + +pub fn validate_document_intelligence_environment( + headers: Vec<(String, String)>, + api_key: Option<&str>, + azure_ad_token: Option<&str>, + env_lookup: &dyn Fn(&str) -> Option, +) -> Result, Error> { + if crate::http_utils::has_header(&headers, "Authorization") + || crate::http_utils::has_header(&headers, "Ocp-Apim-Subscription-Key") + { + return Ok(headers); + } + if let Ok(api_key) = resolve_document_intelligence_api_key(api_key, env_lookup) { + return Ok(prepend_auth_header( + headers, + "Ocp-Apim-Subscription-Key", + api_key, + )); + } + non_empty(azure_ad_token) + .map(|token| prepend_auth_header(headers, "Authorization", format!("Bearer {token}"))) + .ok_or_else(|| { + Error::Auth( + "Missing Azure Document Intelligence credentials - set AZURE_DOCUMENT_INTELLIGENCE_API_KEY or provide azure_ad_token" + .to_string(), + ) + }) +} + +fn encode_model_id(model: &str) -> Result { let model_id = model.rsplit('/').next().unwrap_or(model); - model_id + if matches!(model_id, "." | "..") { + return Err(Error::InvalidRequest( + "model_id cannot be a dot path segment".to_string(), + )); + } + Ok(model_id .bytes() .flat_map(|byte| match byte { b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { @@ -108,7 +176,7 @@ fn encode_model_id(model: &str) -> String { } _ => format!("%{byte:02X}").chars().collect(), }) - .collect() + .collect()) } fn pages_token_is_valid(token: &str) -> bool { @@ -147,6 +215,11 @@ fn normalize_pages_param(pages: &Value) -> Result, Error> { if values.is_empty() { return Ok(None); } + if values.iter().any(Value::is_boolean) { + return Err(Error::InvalidRequest( + "`pages` must be integers, not booleans".to_string(), + )); + } if values.iter().all(Value::is_i64) { let mut pages = BTreeSet::new(); for value in values { @@ -232,6 +305,38 @@ fn normalize_features_param(features: &Value) -> Result, Error> { } } +fn normalize_req_format(req_format: &Value) -> Result { + match req_format.as_str() { + Some(value @ ("native" | "litellm")) => Ok(value.to_string()), + _ => Err(Error::InvalidRequest(format!( + "Invalid `req_format` for Azure Document Intelligence: {req_format:?}. Expected 'native' or 'litellm'." + ))), + } +} + +pub fn map_document_intelligence_ocr_params( + non_default_params: &Map, +) -> Result, Error> { + let mut mapped = Map::new(); + if let Some(pages) = non_default_params.get("pages") + && let Some(normalized) = normalize_pages_param(pages)? + { + mapped.insert("pages".to_string(), Value::String(normalized)); + } + if let Some(features) = non_default_params.get("features") + && let Some(normalized) = normalize_features_param(features)? + { + mapped.insert("features".to_string(), Value::String(normalized)); + } + if let Some(req_format) = non_default_params.get("req_format") { + mapped.insert( + "req_format".to_string(), + Value::String(normalize_req_format(req_format)?), + ); + } + Ok(mapped) +} + pub fn complete_document_intelligence_url( api_base: Option<&str>, model: &str, @@ -242,7 +347,7 @@ pub fn complete_document_intelligence_url( let mut url = format!( "{}/documentintelligence/documentModels/{}:analyze?api-version={}", endpoint.trim_end_matches('/'), - encode_model_id(model), + encode_model_id(model)?, AZURE_DOCUMENT_INTELLIGENCE_API_VERSION ); @@ -260,6 +365,10 @@ pub fn complete_document_intelligence_url( url.push_str(&normalized); } + if let Some(req_format) = optional_params.get("req_format") { + normalize_req_format(req_format)?; + } + Ok(url) } @@ -327,11 +436,78 @@ fn page_dimensions(page: &Map) -> Value { }) } +fn transform_document_intelligence_response( + model: &str, + response_json: Value, + preserve_native_response: bool, +) -> Result { + let response = response_json + .as_object() + .ok_or_else(|| Error::InvalidType { + expected: "object", + actual: json_type_name(&response_json), + })?; + let status = response + .get("status") + .and_then(Value::as_str) + .ok_or(Error::MissingField("status"))?; + if status != "succeeded" { + return Err(Error::InvalidResponse(format!( + "Azure Document Intelligence analysis failed with status: {status}" + ))); + } + + let analyze_result = response.get("analyzeResult").and_then(Value::as_object); + let azure_pages = analyze_result + .and_then(|result| result.get("pages")) + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + let pages = azure_pages + .iter() + .filter_map(Value::as_object) + .map(|page| { + let page_number = page.get("pageNumber").and_then(Value::as_i64).unwrap_or(1); + json!({ + "index": page_number - 1, + "markdown": page_markdown(page), + "dimensions": page_dimensions(page), + }) + }) + .collect::>(); + let extra_fields = ["content", "tables", "keyValuePairs"] + .into_iter() + .map(|field| { + ( + field.to_string(), + analyze_result + .and_then(|result| result.get(field)) + .cloned() + .unwrap_or(Value::Null), + ) + }) + .collect(); + + Ok(OcrResponseData { + usage_info: Some(json!({ + "pages_processed": pages.len(), + "doc_size_bytes": null, + })), + pages, + model: model.to_string(), + document_annotation: None, + object: "ocr".to_string(), + extra_fields, + provider_native_response: preserve_native_response.then_some(response_json), + }) +} + impl OcrProviderConfig for AzureAiOcrConfig { fn supported_ocr_params(&self) -> &'static [&'static str] { MISTRAL_OCR_CONFIG.supported_ocr_params() } + #[tracing::instrument(target = "litellm::function_trace", level = "trace", skip_all)] fn transform_ocr_request( &self, model: &str, @@ -349,6 +525,7 @@ impl OcrProviderConfig for AzureAiOcrConfig { MISTRAL_OCR_CONFIG.transform_ocr_response(model, response_json) } + #[tracing::instrument(target = "litellm::function_trace", level = "trace", skip_all)] fn complete_url( &self, api_base: Option<&str>, @@ -373,10 +550,25 @@ impl OcrProviderConfig for AzureAiOcrConfig { } impl OcrProviderConfig for AzureDocumentIntelligenceOcrConfig { + #[tracing::instrument(target = "litellm::function_trace", level = "trace", skip_all)] fn supported_ocr_params(&self) -> &'static [&'static str] { AZURE_DOCUMENT_INTELLIGENCE_SUPPORTED_OCR_PARAMS } + #[tracing::instrument(target = "litellm::function_trace", level = "trace", skip_all)] + fn map_ocr_params(&self, non_default_params: &Map) -> Map { + map_document_intelligence_ocr_params(non_default_params).unwrap_or_else(|_| { + non_default_params + .iter() + .filter(|(name, _)| { + AZURE_DOCUMENT_INTELLIGENCE_SUPPORTED_OCR_PARAMS.contains(&name.as_str()) + }) + .map(|(name, value)| (name.clone(), value.clone())) + .collect() + }) + } + + #[tracing::instrument(target = "litellm::function_trace", level = "trace", skip_all)] fn transform_ocr_request( &self, _model: &str, @@ -402,59 +594,29 @@ impl OcrProviderConfig for AzureDocumentIntelligenceOcrConfig { }) } + #[tracing::instrument(target = "litellm::function_trace", level = "trace", skip_all)] fn transform_ocr_response( &self, model: &str, response_json: Value, ) -> Result { - let response = response_json - .as_object() - .ok_or_else(|| Error::InvalidType { - expected: "object", - actual: json_type_name(&response_json), - })?; - let status = response - .get("status") - .and_then(Value::as_str) - .ok_or(Error::MissingField("status"))?; - if status != "succeeded" { - return Err(Error::InvalidResponse(format!( - "Azure Document Intelligence analysis failed with status: {status}" - ))); - } - - let azure_pages = response - .get("analyzeResult") - .and_then(|result| result.get("pages")) - .and_then(Value::as_array) - .cloned() - .unwrap_or_default(); - - let pages = azure_pages - .iter() - .filter_map(Value::as_object) - .map(|page| { - let page_number = page.get("pageNumber").and_then(Value::as_i64).unwrap_or(1); - json!({ - "index": page_number - 1, - "markdown": page_markdown(page), - "dimensions": page_dimensions(page), - }) - }) - .collect::>(); - - Ok(OcrResponseData { - usage_info: Some(json!({ - "pages_processed": pages.len(), - "doc_size_bytes": null, - })), - pages, - model: model.to_string(), - document_annotation: None, - object: "ocr".to_string(), - }) + transform_document_intelligence_response(model, response_json, false) } + fn transform_ocr_response_with_params( + &self, + model: &str, + response_json: Value, + optional_params: &Map, + ) -> Result { + transform_document_intelligence_response( + model, + response_json, + optional_params.get("req_format").and_then(Value::as_str) == Some("native"), + ) + } + + #[tracing::instrument(target = "litellm::function_trace", level = "trace", skip_all)] fn complete_url( &self, api_base: Option<&str>, @@ -485,6 +647,47 @@ impl OcrProviderConfig for AzureDocumentIntelligenceOcrConfig { #[cfg(test)] mod tests { use super::*; + use rstest::{fixture, rstest}; + + const ENDPOINT: &str = "https://example.cognitiveservices.azure.com"; + + #[fixture] + fn document_intelligence_config() -> AzureDocumentIntelligenceOcrConfig { + AzureDocumentIntelligenceOcrConfig + } + + fn header_value<'a>(headers: &'a [(String, String)], name: &str) -> Option<&'a str> { + headers + .iter() + .find(|(header_name, _)| header_name.eq_ignore_ascii_case(name)) + .map(|(_, value)| value.as_str()) + } + + fn native_operation() -> Value { + json!({ + "status": "succeeded", + "createdDateTime": "2026-07-02T00:00:00Z", + "analyzeResult": { + "content": "Invoice\nInvoice No: INV-12345\nTotal: $100.00", + "pages": [{ + "pageNumber": 1, + "width": 8.5, + "height": 11, + "unit": "inch", + "angle": 0.13, + "lines": [ + {"content": "Invoice"}, + {"content": "Invoice No: INV-12345"}, + {"content": "Total: $100.00"} + ], + "words": [{"content": "Invoice", "confidence": 0.994}] + }], + "tables": [{"rowCount": 1, "columnCount": 1}], + "keyValuePairs": [{"key": {"content": "Invoice No"}, "value": {"content": "INV-12345"}}], + "paragraphs": [{"content": "Invoice"}] + } + }) + } #[test] fn azure_ai_reuses_mistral_body_transform() { @@ -568,6 +771,11 @@ mod tests { #[test] fn document_intelligence_url_omits_empty_feature_list() { let params = serde_json::Map::from_iter([("features".to_string(), json!([]))]); + assert!( + map_document_intelligence_ocr_params(¶ms) + .expect("empty features map") + .is_empty() + ); let url = complete_document_intelligence_url( Some("https://example.cognitiveservices.azure.com"), "prebuilt-layout", @@ -582,41 +790,52 @@ mod tests { ); } - #[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", - ¶ms, - &|_| None, - ) - .expect_err("invalid features must fail"); + #[rstest] + #[case::query_injection(json!("keyValuePairs&pages=9"))] + #[case::spaces(json!("key value pairs"))] + #[case::empty_string(json!(""))] + #[case::integer_list(json!([1, 2]))] + #[case::nested_list(json!([["keyValuePairs"]]))] + #[case::object(json!({"feature": "keyValuePairs"}))] + #[case::number(json!(5))] + fn document_intelligence_url_rejects_invalid_features(#[case] features: Value) { + let params = serde_json::Map::from_iter([("features".to_string(), features)]); + let error = complete_document_intelligence_url( + Some("https://example.cognitiveservices.azure.com"), + "prebuilt-layout", + ¶ms, + &|_| None, + ) + .expect_err("invalid features must fail"); - assert!( - matches!(error, Error::InvalidRequest(message) if message.contains("Invalid `features`")), - "features={features:?}" - ); - } + assert!(matches!( + error, + Error::InvalidRequest(message) if message.contains("Invalid `features`") + )); } #[test] fn document_intelligence_maps_features() { - let params = Map::from_iter([ - ("features".to_string(), json!(["keyValuePairs"])), - ("unsupported".to_string(), json!(true)), - ]); + for (features, expected) in [ + (json!(["keyValuePairs"]), "keyValuePairs"), + ( + json!(["keyValuePairs", "languages"]), + "keyValuePairs,languages", + ), + (json!("keyValuePairs"), "keyValuePairs"), + (json!("keyValuePairs,languages"), "keyValuePairs,languages"), + (json!("keyValuePairs, languages"), "keyValuePairs,languages"), + ] { + let params = Map::from_iter([ + ("features".to_string(), features), + ("unsupported".to_string(), json!(true)), + ]); - assert_eq!( - AZURE_DOCUMENT_INTELLIGENCE_OCR_CONFIG.map_ocr_params(¶ms), - Map::from_iter([("features".to_string(), json!(["keyValuePairs"]))]) - ); + assert_eq!( + AZURE_DOCUMENT_INTELLIGENCE_OCR_CONFIG.map_ocr_params(¶ms), + Map::from_iter([("features".to_string(), json!(expected))]) + ); + } } #[test] @@ -641,6 +860,9 @@ mod tests { json!({ "status": "succeeded", "analyzeResult": { + "content": "hello\nworld", + "tables": [{"rowCount": 1, "columnCount": 1}], + "keyValuePairs": [{"key": {"content": "Total"}, "value": {"content": "$100.00"}}], "pages": [{ "pageNumber": 2, "width": 8.5, @@ -656,9 +878,505 @@ mod tests { assert_eq!(response.pages[0]["index"], 1); assert_eq!(response.pages[0]["markdown"], "hello\nworld"); assert_eq!(response.pages[0]["dimensions"]["width"], 816); + assert_eq!(response.extra_fields["content"], "hello\nworld"); + assert_eq!(response.extra_fields["tables"][0]["rowCount"], 1); + assert_eq!( + response.extra_fields["keyValuePairs"][0]["key"]["content"], + "Total" + ); + assert_eq!(response.object, "ocr"); assert_eq!( response.usage_info, Some(json!({"pages_processed": 1, "doc_size_bytes": null})) ); } + + #[test] + fn azure_document_intelligence_model_id_is_encoded() { + let url = complete_document_intelligence_url( + Some(ENDPOINT), + "prebuilt-layout?x=1#frag", + &Map::new(), + &|_| None, + ) + .expect("url builds"); + + assert_eq!( + url, + "https://example.cognitiveservices.azure.com/documentintelligence/documentModels/prebuilt-layout%3Fx%3D1%23frag:analyze?api-version=2024-11-30" + ); + } + + #[test] + fn azure_document_intelligence_dot_segment_model_id_is_rejected() { + let error = complete_document_intelligence_url( + Some(ENDPOINT), + "azure_ai/doc-intelligence/..", + &Map::new(), + &|_| None, + ) + .expect_err("dot segment must fail"); + + assert_eq!( + error, + Error::InvalidRequest("model_id cannot be a dot path segment".to_string()) + ); + } + + #[test] + fn document_intelligence_async_response_preserves_normalized_fields() { + let response = AZURE_DOCUMENT_INTELLIGENCE_OCR_CONFIG + .transform_ocr_response( + "azure_ai/doc-intelligence/prebuilt-layout", + native_operation(), + ) + .expect("response transforms"); + + assert_eq!( + response.pages[0]["markdown"], + "Invoice\nInvoice No: INV-12345\nTotal: $100.00" + ); + assert_eq!( + response.pages[0]["dimensions"], + json!({"width": 816, "height": 1056, "dpi": 96}) + ); + assert_eq!(response.extra_fields["tables"][0]["rowCount"], 1); + assert_eq!( + response.extra_fields["keyValuePairs"][0]["key"]["content"], + "Invoice No" + ); + } + + #[test] + fn document_intelligence_response_tolerates_missing_native_fields() { + let response = AZURE_DOCUMENT_INTELLIGENCE_OCR_CONFIG + .transform_ocr_response( + "azure_ai/doc-intelligence/prebuilt-read", + json!({ + "status": "succeeded", + "analyzeResult": { + "pages": [{ + "pageNumber": 1, + "width": 8.5, + "height": 11, + "unit": "inch", + "lines": [{"content": "hello"}] + }] + } + }), + ) + .expect("missing optional fields are allowed"); + + assert_eq!(response.pages[0]["markdown"], "hello"); + assert_eq!(response.extra_fields["content"], Value::Null); + assert_eq!(response.extra_fields["tables"], Value::Null); + assert_eq!(response.extra_fields["keyValuePairs"], Value::Null); + } + + #[test] + fn document_intelligence_non_succeeded_status_is_rejected() { + let error = AZURE_DOCUMENT_INTELLIGENCE_OCR_CONFIG + .transform_ocr_response( + "azure_ai/doc-intelligence/prebuilt-layout", + json!({"status": "failed"}), + ) + .expect_err("failed status must fail"); + + assert_eq!( + error, + Error::InvalidResponse( + "Azure Document Intelligence analysis failed with status: failed".to_string() + ) + ); + } + + #[test] + fn document_intelligence_supported_params_include_features() { + assert_eq!( + AZURE_DOCUMENT_INTELLIGENCE_OCR_CONFIG.supported_ocr_params(), + &["pages", "features", "req_format"] + ); + } + + #[test] + fn document_intelligence_native_format_carries_raw_operation() { + let operation = native_operation(); + let response = AZURE_DOCUMENT_INTELLIGENCE_OCR_CONFIG + .transform_ocr_response_with_params( + "azure_ai/doc-intelligence/prebuilt-layout", + operation.clone(), + &Map::from_iter([("req_format".to_string(), json!("native"))]), + ) + .expect("native response transforms"); + + assert_eq!(response.provider_native_response, Some(operation)); + assert_eq!( + response.extra_fields["content"], + "Invoice\nInvoice No: INV-12345\nTotal: $100.00" + ); + assert_eq!( + response.usage_info.as_ref().expect("usage")["pages_processed"], + 1 + ); + } + + #[test] + fn document_intelligence_async_native_format_carries_raw_operation() { + let operation = native_operation(); + let response = AZURE_DOCUMENT_INTELLIGENCE_OCR_CONFIG + .transform_ocr_response_with_params( + "azure_ai/doc-intelligence/prebuilt-layout", + operation.clone(), + &Map::from_iter([("req_format".to_string(), json!("native"))]), + ) + .expect("native response transforms"); + + assert_eq!(response.provider_native_response, Some(operation)); + assert_eq!( + response.usage_info.as_ref().expect("usage")["pages_processed"], + 1 + ); + } + + #[rstest] + #[case::default(Map::new())] + #[case::litellm(Map::from_iter([("req_format".to_string(), json!("litellm"))]))] + fn document_intelligence_default_format_omits_raw_operation( + #[case] optional_params: Map, + ) { + let response = AZURE_DOCUMENT_INTELLIGENCE_OCR_CONFIG + .transform_ocr_response_with_params( + "azure_ai/doc-intelligence/prebuilt-layout", + native_operation(), + &optional_params, + ) + .expect("response transforms"); + + assert_eq!(response.provider_native_response, None); + assert_eq!(response.extra_fields["tables"][0]["rowCount"], 1); + } + + #[rstest] + #[case::native("native")] + #[case::litellm("litellm")] + fn document_intelligence_maps_req_format(#[case] req_format: &str) { + let mapped = map_document_intelligence_ocr_params(&Map::from_iter([( + "req_format".to_string(), + json!(req_format), + )])) + .expect("req_format maps"); + + assert_eq!( + mapped, + Map::from_iter([("req_format".to_string(), json!(req_format))]) + ); + } + + #[test] + fn document_intelligence_rejects_unknown_req_format() { + let error = map_document_intelligence_ocr_params(&Map::from_iter([( + "req_format".to_string(), + json!("azure"), + )])) + .expect_err("unknown req_format must fail"); + + assert!( + matches!(error, Error::InvalidRequest(message) if message.contains("Invalid `req_format`")) + ); + } + + #[test] + fn document_intelligence_url_omits_req_format() { + let url = complete_document_intelligence_url( + Some(ENDPOINT), + "prebuilt-layout", + &Map::from_iter([("req_format".to_string(), json!("native"))]), + &|_| None, + ) + .expect("url builds"); + + assert!(!url.contains("req_format")); + } + + #[test] + fn document_intelligence_validate_environment_uses_subscription_key() { + let headers = + validate_document_intelligence_environment(Vec::new(), Some("my-key"), None, &|_| None) + .expect("api key authenticates"); + + assert_eq!( + header_value(&headers, "Ocp-Apim-Subscription-Key"), + Some("my-key") + ); + } + + #[test] + fn document_intelligence_validate_environment_falls_back_to_entra_token() { + let headers = validate_document_intelligence_environment( + Vec::new(), + None, + Some("entra-token"), + &|_| None, + ) + .expect("Entra token authenticates"); + + assert_eq!( + header_value(&headers, "Authorization"), + Some("Bearer entra-token") + ); + assert_eq!(header_value(&headers, "Ocp-Apim-Subscription-Key"), None); + } + + #[test] + fn document_intelligence_supported_params_include_pages_features_and_req_format() { + assert_eq!( + AZURE_DOCUMENT_INTELLIGENCE_OCR_CONFIG.supported_ocr_params(), + &["pages", "features", "req_format"] + ); + } + + #[test] + fn document_intelligence_maps_zero_based_page_list() { + let mapped = map_document_intelligence_ocr_params(&Map::from_iter([( + "pages".to_string(), + json!([0, 1, 2]), + )])) + .expect("pages map"); + + assert_eq!( + mapped, + Map::from_iter([("pages".to_string(), json!("1,2,3"))]) + ); + } + + #[test] + fn document_intelligence_page_mapping_dedupes_and_sorts() { + let mapped = map_document_intelligence_ocr_params(&Map::from_iter([( + "pages".to_string(), + json!([2, 0, 0, 1]), + )])) + .expect("pages map"); + + assert_eq!(mapped["pages"], "1,2,3"); + } + + #[test] + fn document_intelligence_page_mapping_omits_empty_list() { + let mapped = map_document_intelligence_ocr_params(&Map::from_iter([( + "pages".to_string(), + json!([]), + )])) + .expect("empty pages map"); + + assert!(mapped.is_empty()); + } + + #[test] + fn document_intelligence_page_mapping_accepts_native_range() { + let mapped = map_document_intelligence_ocr_params(&Map::from_iter([( + "pages".to_string(), + json!("3-9"), + )])) + .expect("range maps"); + + assert_eq!(mapped["pages"], "3-9"); + } + + #[test] + fn document_intelligence_page_mapping_strips_spaces() { + let mapped = map_document_intelligence_ocr_params(&Map::from_iter([( + "pages".to_string(), + json!("1-3, 5"), + )])) + .expect("range maps"); + + assert_eq!(mapped["pages"], "1-3,5"); + } + + #[test] + fn document_intelligence_page_mapping_accepts_string_tokens() { + let mapped = map_document_intelligence_ocr_params(&Map::from_iter([( + "pages".to_string(), + json!(["1", "3-5"]), + )])) + .expect("tokens map"); + + assert_eq!(mapped["pages"], "1,3-5"); + } + + #[test] + fn document_intelligence_page_mapping_rejects_invalid_string() { + let error = map_document_intelligence_ocr_params(&Map::from_iter([( + "pages".to_string(), + json!("a,b"), + )])) + .expect_err("invalid pages must fail"); + + assert!( + matches!(error, Error::InvalidRequest(message) if message.contains("Invalid `pages` string")) + ); + } + + #[test] + fn document_intelligence_page_mapping_rejects_negative_index() { + let error = map_document_intelligence_ocr_params(&Map::from_iter([( + "pages".to_string(), + json!([-1]), + )])) + .expect_err("negative pages must fail"); + + assert!( + matches!(error, Error::InvalidRequest(message) if message.contains("must be >= 0")) + ); + } + + #[test] + fn document_intelligence_page_mapping_rejects_bool_list() { + let error = map_document_intelligence_ocr_params(&Map::from_iter([( + "pages".to_string(), + json!([true, false]), + )])) + .expect_err("boolean pages must fail"); + + assert!( + matches!(error, Error::InvalidRequest(message) if message.contains("integers, not booleans")) + ); + } + + #[test] + fn document_intelligence_page_mapping_rejects_unsupported_type() { + let error = map_document_intelligence_ocr_params(&Map::from_iter([( + "pages".to_string(), + json!(5), + )])) + .expect_err("unsupported pages must fail"); + + assert!( + matches!(error, Error::InvalidRequest(message) if message.contains("Mistral-style")) + ); + } + + #[test] + fn document_intelligence_url_appends_pages_query() { + let url = complete_document_intelligence_url( + Some("https://example.cognitiveservices.azure.com/"), + "azure_ai/doc-intelligence/prebuilt-layout", + &Map::from_iter([("pages".to_string(), json!("1-3,5"))]), + &|_| None, + ) + .expect("url builds"); + + assert!(url.contains("api-version=2024-11-30")); + assert!(url.contains("pages=1-3,5")); + assert!(url.contains("/documentintelligence/documentModels/prebuilt-layout:analyze")); + } + + #[test] + fn document_intelligence_url_has_no_pages_when_params_are_empty() { + let url = complete_document_intelligence_url( + Some(ENDPOINT), + "prebuilt-layout", + &Map::new(), + &|_| None, + ) + .expect("url builds"); + + assert!(!url.contains("pages=")); + } + + #[rstest] + fn document_intelligence_request_keeps_pages_out_of_body( + document_intelligence_config: AzureDocumentIntelligenceOcrConfig, + ) { + let request = document_intelligence_config + .transform_ocr_request( + "prebuilt-layout", + json!({"type": "document_url", "document_url": "https://example.com/x.pdf"}), + Map::from_iter([("pages".to_string(), json!("1,2,3"))]), + ) + .expect("request transforms"); + + assert_eq!( + request.data, + json!({"urlSource": "https://example.com/x.pdf"}) + ); + } + + #[test] + fn document_intelligence_mistral_pages_flow_to_query_only() { + let mapped = map_document_intelligence_ocr_params(&Map::from_iter([( + "pages".to_string(), + json!([2, 3, 4, 5, 6, 7, 8]), + )])) + .expect("pages map"); + let url = + complete_document_intelligence_url(Some(ENDPOINT), "prebuilt-layout", &mapped, &|_| { + None + }) + .expect("url builds"); + let request = AZURE_DOCUMENT_INTELLIGENCE_OCR_CONFIG + .transform_ocr_request( + "prebuilt-layout", + json!({"type": "document_url", "document_url": "https://example.com/x.pdf"}), + mapped, + ) + .expect("request transforms"); + + assert!(url.contains("pages=3,4,5,6,7,8,9")); + assert_eq!( + request.data, + json!({"urlSource": "https://example.com/x.pdf"}) + ); + } + + #[test] + fn document_intelligence_endpoint_ignores_generic_azure_ai_base() { + let resolved = resolve_document_intelligence_endpoint(None, &|name| match name { + AZURE_DOCUMENT_INTELLIGENCE_ENDPOINT_ENV => Some(ENDPOINT.to_string()), + AZURE_AI_API_BASE_ENV => Some("https://generic.example.com".to_string()), + _ => None, + }) + .expect("endpoint resolves"); + + assert_eq!(resolved, ENDPOINT); + } + + #[test] + fn document_intelligence_endpoint_honors_explicit_api_base() { + let resolved = resolve_document_intelligence_endpoint( + Some("https://my-di.cognitiveservices.azure.com"), + &|name| match name { + AZURE_DOCUMENT_INTELLIGENCE_ENDPOINT_ENV => Some(ENDPOINT.to_string()), + AZURE_AI_API_BASE_ENV => Some("https://generic.example.com".to_string()), + _ => None, + }, + ) + .expect("endpoint resolves"); + + assert_eq!(resolved, "https://my-di.cognitiveservices.azure.com"); + } + + #[test] + fn azure_ai_mistral_ocr_uses_generic_api_base() { + let resolved = resolve_azure_ai_api_base(None, &|name| match name { + AZURE_AI_API_BASE_ENV => Some("https://generic-azure-ai.example.com".to_string()), + AZURE_DOCUMENT_INTELLIGENCE_ENDPOINT_ENV => Some(ENDPOINT.to_string()), + _ => None, + }) + .expect("api base resolves"); + + assert_eq!(resolved, "https://generic-azure-ai.example.com"); + } + + #[test] + fn azure_ai_ocr_authenticates_with_entra_token() { + let headers = + validate_azure_ai_environment(Vec::new(), None, Some("entra-token"), &|_| None) + .expect("Entra token authenticates"); + + assert_eq!( + header_value(&headers, "Authorization"), + Some("Bearer entra-token") + ); + } } diff --git a/litellm-rust/crates/core/src/providers/mistral/ocr/transformation.rs b/litellm-rust/crates/core/src/providers/mistral/ocr/transformation.rs index 0125886aac1..11e8fe7db18 100644 --- a/litellm-rust/crates/core/src/providers/mistral/ocr/transformation.rs +++ b/litellm-rust/crates/core/src/providers/mistral/ocr/transformation.rs @@ -134,6 +134,8 @@ impl OcrProviderConfig for MistralOcrConfig { document_annotation, usage_info, object: "ocr".to_string(), + extra_fields: Map::new(), + provider_native_response: None, }) } diff --git a/litellm-rust/crates/core/src/providers/mod.rs b/litellm-rust/crates/core/src/providers/mod.rs index 805600d6dbe..c0c2c69831b 100644 --- a/litellm-rust/crates/core/src/providers/mod.rs +++ b/litellm-rust/crates/core/src/providers/mod.rs @@ -4,4 +4,5 @@ pub mod azure_ai; pub mod bedrock; pub mod mistral; pub mod openai; +pub mod reducto; pub mod vertex_ai; diff --git a/litellm-rust/crates/core/src/providers/reducto/mod.rs b/litellm-rust/crates/core/src/providers/reducto/mod.rs new file mode 100644 index 00000000000..3621ff6a2fd --- /dev/null +++ b/litellm-rust/crates/core/src/providers/reducto/mod.rs @@ -0,0 +1 @@ +pub mod ocr; diff --git a/litellm-rust/crates/core/src/providers/reducto/ocr/mod.rs b/litellm-rust/crates/core/src/providers/reducto/ocr/mod.rs new file mode 100644 index 00000000000..8acee8f770c --- /dev/null +++ b/litellm-rust/crates/core/src/providers/reducto/ocr/mod.rs @@ -0,0 +1,4 @@ +pub mod transformation; + +#[cfg(test)] +mod tests; diff --git a/litellm-rust/crates/core/src/providers/reducto/ocr/tests.rs b/litellm-rust/crates/core/src/providers/reducto/ocr/tests.rs new file mode 100644 index 00000000000..2b66d058b5d --- /dev/null +++ b/litellm-rust/crates/core/src/providers/reducto/ocr/tests.rs @@ -0,0 +1,202 @@ +use rstest::{fixture, rstest}; +use serde_json::{Value, json}; + +use super::transformation::*; +use crate::ocr::transformation::OcrProviderConfig; + +#[fixture] +fn parse_response() -> Value { + json!({ + "job_id": "job_123", + "usage": {"num_pages": 3, "credits": 3}, + "result": { + "chunks": [ + { + "content": "Page 1 block A", + "blocks": [{ + "content": "Page 1 block A", + "bbox": {"page": 1}, + "kind": "text", + }], + }, + { + "content": "Page 2 block A", + "blocks": [{ + "content": "Page 2 block A", + "bbox": {"page": 2}, + "kind": "table", + }], + }, + { + "content": "Page 1 block B", + "blocks": [{ + "content": "Page 1 block B", + "bbox": {"page": 1}, + "kind": "text", + }], + }, + { + "content": "Page 3 block A", + "blocks": [{ + "content": "Page 3 block A", + "bbox": {"page": 3}, + "kind": "figure", + }], + }, + ], + }, + }) +} + +#[rstest] +fn test_parse_v3_file_upload_and_response_mapping(parse_response: Value) { + let source = classify_document_source("data:application/pdf;base64,JVBERi0xLjQ=") + .expect("PDF data URI should be valid"); + let upload = build_upload_request( + source, + "Bearer test-key", + Some("https://platform.reducto.ai"), + ) + .expect("data URI should require upload"); + assert_eq!(upload.url, "https://platform.reducto.ai/upload"); + assert_eq!(upload.authorization, "Bearer test-key"); + assert_eq!(upload.file_name, "document"); + assert_eq!(upload.mime_type, "application/pdf"); + assert_eq!(upload.bytes, b"%PDF-1.4"); + + let optional_params = json!({ + "formatting": {"table_output_format": "html"}, + "retrieval": {"chunk_mode": "section"}, + "settings": {"ocr_system": "standard"}, + }) + .as_object() + .expect("params should be an object") + .clone(); + let request = build_parse_v3_request("reducto://uploaded.pdf", optional_params); + assert_eq!( + request.data, + json!({ + "input": "reducto://uploaded.pdf", + "formatting": {"table_output_format": "html"}, + "retrieval": {"chunk_mode": "section"}, + "settings": {"ocr_system": "standard"}, + }) + ); + + let transformed = transform_reducto_response("parse-v3", parse_response.clone()) + .expect("response should transform"); + assert_eq!( + transformed.usage_info, + Some(json!({"pages_processed": 3, "credits": 3})) + ); + assert_eq!(transformed.pages.len(), 3); + assert_eq!( + transformed.pages[0], + json!({ + "index": 0, + "markdown": "Page 1 block A\n\nPage 1 block B", + "blocks": [ + {"content": "Page 1 block A", "bbox": {"page": 1}, "kind": "text"}, + {"content": "Page 1 block B", "bbox": {"page": 1}, "kind": "text"}, + ], + }) + ); + assert_eq!(transformed.pages[1]["markdown"], "Page 2 block A"); + assert_eq!(transformed.pages[2]["markdown"], "Page 3 block A"); + assert_eq!(transformed.provider_native_response, Some(parse_response)); +} + +#[rstest] +fn test_parse_v3_reducto_id_passthrough_skips_upload(parse_response: Value) { + let document = json!({ + "type": "document_url", + "document_url": "reducto://already-uploaded.pdf", + }); + let source = extract_document_source(&document).expect("Reducto ID should be valid"); + assert!(build_upload_request(source.clone(), "Bearer test-key", None).is_none()); + assert_eq!( + source, + ReductoDocumentSource::FileId("reducto://already-uploaded.pdf".to_string()) + ); + + let request = REDUCTO_PARSE_V3_CONFIG + .transform_ocr_request( + "parse-v3", + document, + json!({"retrieval": {"chunk_mode": "section"}}) + .as_object() + .expect("params should be object") + .clone(), + ) + .expect("direct ID should transform"); + assert_eq!(request.data["input"], "reducto://already-uploaded.pdf"); + assert_eq!(request.data["retrieval"]["chunk_mode"], "section"); + + let response = REDUCTO_PARSE_V3_CONFIG + .transform_ocr_response("parse-v3", parse_response) + .expect("response should transform"); + assert!( + response.pages[0]["markdown"] + .as_str() + .expect("markdown should be string") + .starts_with("Page 1 block A") + ); +} + +#[rstest] +fn test_parse_legacy_wraps_enhance_under_options() { + let request = build_parse_legacy_request( + "reducto://legacy.pdf", + json!({"enhance": {"agentic": [{"type": "table"}]}}) + .as_object() + .expect("params should be object"), + ); + assert_eq!( + request.data, + json!({ + "document_url": "reducto://legacy.pdf", + "options": {"enhance": {"agentic": [{"type": "table"}]}}, + }) + ); +} + +#[rstest] +fn test_parse_v3_image_data_uri_upload_uses_image_mime() { + let source = classify_document_source("data:image/png;base64,iVBORw0KGgo=") + .expect("PNG data URI should be valid"); + let upload = build_upload_request( + source, + "Bearer programmatic-key", + Some("https://custom.reducto.test/"), + ) + .expect("data URI should require upload"); + assert_eq!(upload.url, "https://custom.reducto.test/upload"); + assert_eq!(upload.authorization, "Bearer programmatic-key"); + assert_eq!(upload.mime_type, "image/png"); + assert_eq!(upload.bytes, b"\x89PNG\r\n\x1a\n"); +} + +#[rstest] +#[case::http("http://example.com/document.pdf")] +#[case::https("https://example.com/document.pdf")] +fn test_parse_v3_rejects_plain_http_urls(#[case] source: &str) { + let error = classify_document_source(source).expect_err("plain URL should be rejected"); + assert!(error.to_string().contains("upload the file first")); +} + +#[rstest] +fn test_parse_v3_uses_programmatic_api_key_over_env() { + let key = resolve_api_key(Some("passed-key"), &|_| Some("env-reducto-key".to_string())) + .expect("explicit key should resolve"); + assert_eq!(key, "passed-key"); + + let headers = REDUCTO_PARSE_V3_CONFIG + .validate_environment(Vec::new(), Some("passed-key"), &|_| { + Some("env-reducto-key".to_string()) + }) + .expect("headers should validate"); + assert_eq!( + headers, + vec![("Authorization".to_string(), "Bearer passed-key".to_string())] + ); +} diff --git a/litellm-rust/crates/core/src/providers/reducto/ocr/transformation.rs b/litellm-rust/crates/core/src/providers/reducto/ocr/transformation.rs new file mode 100644 index 00000000000..b8507541e18 --- /dev/null +++ b/litellm-rust/crates/core/src/providers/reducto/ocr/transformation.rs @@ -0,0 +1,407 @@ +use std::collections::BTreeMap; + +use base64::Engine; +use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +use serde_json::{Map, Value, json}; + +use crate::error::{Error, json_type_name}; +use crate::ocr::transformation::OcrProviderConfig; +use crate::ocr::types::{OcrRequestData, OcrResponseData}; + +pub const REDUCTO_API_BASE: &str = "https://platform.reducto.ai"; +pub const REDUCTO_API_KEY_ENV: &str = "REDUCTO_API_KEY"; +pub const REDUCTO_ID_PREFIX: &str = "reducto://"; + +const PARSE_V3_SUPPORTED_OCR_PARAMS: &[&str] = &["formatting", "retrieval", "settings"]; +const PARSE_LEGACY_SUPPORTED_OCR_PARAMS: &[&str] = &["enhance"]; +const MISSING_KEY_MESSAGE: &str = "Missing REDUCTO_API_KEY - set it in the environment or pass api_key to litellm.ocr()/litellm.aocr()"; +const DATA_URI_UPLOAD_REQUIRED: &str = + "Reducto data URI upload must complete before OCR request transformation"; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ReductoDocumentSource { + FileId(String), + Upload { bytes: Vec, mime_type: String }, +} + +#[derive(Clone, PartialEq, Eq)] +pub struct ReductoUploadRequest { + pub url: String, + pub authorization: String, + pub file_name: &'static str, + pub bytes: Vec, + pub mime_type: String, +} + +pub struct ReductoParseV3Config; +pub struct ReductoParseLegacyConfig; + +pub const REDUCTO_PARSE_V3_CONFIG: ReductoParseV3Config = ReductoParseV3Config; +pub const REDUCTO_PARSE_LEGACY_CONFIG: ReductoParseLegacyConfig = ReductoParseLegacyConfig; + +pub fn config_for_model(model: &str) -> Option<&'static dyn OcrProviderConfig> { + match model { + "parse-v3" => Some(&REDUCTO_PARSE_V3_CONFIG), + "parse-legacy" => Some(&REDUCTO_PARSE_LEGACY_CONFIG), + _ => None, + } +} + +pub fn normalize_api_base(api_base: Option<&str>) -> String { + api_base + .map(str::trim) + .filter(|base| !base.is_empty()) + .unwrap_or(REDUCTO_API_BASE) + .trim_end_matches('/') + .to_string() +} + +pub fn parse_url(api_base: Option<&str>) -> String { + format!("{}/parse", normalize_api_base(api_base)) +} + +pub fn upload_url(api_base: Option<&str>) -> String { + format!("{}/upload", normalize_api_base(api_base)) +} + +pub fn resolve_api_key( + api_key: Option<&str>, + env_lookup: &dyn Fn(&str) -> Option, +) -> Result { + api_key + .map(str::trim) + .filter(|key| !key.is_empty()) + .map(str::to_string) + .or_else(|| { + env_lookup(REDUCTO_API_KEY_ENV) + .map(|key| key.trim().to_string()) + .filter(|key| !key.is_empty()) + }) + .ok_or_else(|| Error::Auth(MISSING_KEY_MESSAGE.to_string())) +} + +pub fn extract_document_source(document: &Value) -> Result { + let document = document.as_object().ok_or_else(|| Error::InvalidType { + expected: "object", + actual: json_type_name(document), + })?; + let source = document + .get("document_url") + .and_then(Value::as_str) + .filter(|source| !source.is_empty()) + .or_else(|| document.get("image_url").and_then(Value::as_str)) + .ok_or_else(|| { + Error::InvalidRequest( + "Reducto expected OCR preprocessing to produce document_url or image_url" + .to_string(), + ) + })?; + classify_document_source(source) +} + +pub fn classify_document_source(source: &str) -> Result { + if source.starts_with(REDUCTO_ID_PREFIX) { + return Ok(ReductoDocumentSource::FileId(source.to_string())); + } + if source.starts_with("http://") || source.starts_with("https://") { + return Err(Error::InvalidRequest( + "Reducto requires type='file' (auto-uploaded) or a reducto:// id. Plain http(s) URLs are not supported; upload the file first." + .to_string(), + )); + } + if !source.starts_with("data:") { + return Err(Error::InvalidRequest( + "Reducto requires a reducto:// id or a base64 data URI after OCR preprocessing." + .to_string(), + )); + } + + let (header, encoded) = source + .split_once(',') + .ok_or_else(|| Error::InvalidRequest("Invalid Reducto data URI provided.".to_string()))?; + if !header.split(';').any(|part| part == "base64") { + return Err(Error::InvalidRequest( + "Reducto only supports base64-encoded data URIs.".to_string(), + )); + } + + let mime_type = header + .strip_prefix("data:") + .and_then(|header| header.split(';').next()) + .filter(|mime| !mime.is_empty()) + .unwrap_or("application/octet-stream") + .to_string(); + let bytes = BASE64_STANDARD.decode(encoded).map_err(|_| { + Error::InvalidRequest("Invalid Reducto base64 payload provided.".to_string()) + })?; + + Ok(ReductoDocumentSource::Upload { bytes, mime_type }) +} + +pub fn build_upload_request( + source: ReductoDocumentSource, + authorization: &str, + api_base: Option<&str>, +) -> Option { + let ReductoDocumentSource::Upload { bytes, mime_type } = source else { + return None; + }; + + Some(ReductoUploadRequest { + url: upload_url(api_base), + authorization: authorization.to_string(), + file_name: "document", + bytes, + mime_type, + }) +} + +pub fn extract_upload_file_id(response_json: &Value) -> Result<&str, Error> { + response_json + .as_object() + .and_then(|response| response.get("file_id")) + .and_then(Value::as_str) + .filter(|file_id| !file_id.is_empty()) + .ok_or_else(|| { + Error::InvalidResponse(format!( + "Reducto /upload returned 200 without a file_id; got payload={response_json}" + )) + }) +} + +pub fn build_parse_v3_request( + file_id: &str, + optional_params: Map, +) -> OcrRequestData { + let data = std::iter::once(("input".to_string(), Value::String(file_id.to_string()))) + .chain(optional_params) + .collect(); + OcrRequestData { + data: Value::Object(data), + files: None, + } +} + +pub fn build_parse_legacy_request( + file_id: &str, + optional_params: &Map, +) -> OcrRequestData { + let options = optional_params + .get("enhance") + .filter(|enhance| !enhance.is_null()) + .map(|enhance| json!({"options": {"enhance": enhance}})); + let data = match options { + Some(Value::Object(options)) => std::iter::once(( + "document_url".to_string(), + Value::String(file_id.to_string()), + )) + .chain(options) + .collect(), + _ => Map::from_iter([( + "document_url".to_string(), + Value::String(file_id.to_string()), + )]), + }; + OcrRequestData { + data: Value::Object(data), + files: None, + } +} + +fn source_file_id(document: &Value) -> Result { + match extract_document_source(document)? { + ReductoDocumentSource::FileId(file_id) => Ok(file_id), + ReductoDocumentSource::Upload { .. } => Err(Error::Unsupported(DATA_URI_UPLOAD_REQUIRED)), + } +} + +fn page_number(block: &Map) -> Option { + let page = block.get("bbox")?.as_object()?.get("page")?; + page.as_i64() + .or_else(|| page.as_u64().and_then(|page| i64::try_from(page).ok())) + .or_else(|| page.as_str().and_then(|page| page.parse().ok())) +} + +fn chunks(result: &Map) -> &[Value] { + result + .get("chunks") + .and_then(Value::as_array) + .map(Vec::as_slice) + .unwrap_or_default() +} + +fn build_pages(result: &Map) -> Vec { + let blocks_by_page = chunks(result) + .iter() + .filter_map(Value::as_object) + .filter_map(|chunk| chunk.get("blocks").and_then(Value::as_array)) + .flatten() + .filter_map(|block| block.as_object().map(|object| (block, object))) + .filter_map(|(block, object)| page_number(object).map(|page| (page, block.clone()))) + .fold( + BTreeMap::>::new(), + |mut pages, (page, block)| { + pages.entry(page).or_default().push(block); + pages + }, + ); + + if blocks_by_page.is_empty() { + let markdown = chunks(result) + .iter() + .filter_map(Value::as_object) + .filter_map(|chunk| chunk.get("content").and_then(Value::as_str)) + .filter(|content| !content.is_empty()) + .collect::>() + .join("\n\n"); + return if markdown.is_empty() { + Vec::new() + } else { + vec![json!({"index": 0, "markdown": markdown})] + }; + } + + blocks_by_page + .into_iter() + .map(|(page, blocks)| { + let markdown = blocks + .iter() + .filter_map(Value::as_object) + .filter_map(|block| block.get("content").and_then(Value::as_str)) + .filter(|content| !content.is_empty()) + .collect::>() + .join("\n\n"); + json!({ + "index": page.saturating_sub(1).max(0), + "markdown": markdown, + "blocks": blocks, + }) + }) + .collect() +} + +pub fn transform_reducto_response( + model: &str, + response_json: Value, +) -> Result { + let response = response_json + .as_object() + .ok_or_else(|| Error::InvalidType { + expected: "object", + actual: json_type_name(&response_json), + })?; + let empty_result = Map::new(); + let result = match response.get("result") { + Some(Value::Object(result)) => result, + Some(Value::Null) => &empty_result, + Some(_) => { + return Err(Error::InvalidResponse( + "Reducto result must be an object".to_string(), + )); + } + None => response, + }; + let usage = response + .get("usage") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + let usage_info = Some(json!({ + "pages_processed": usage.get("num_pages").cloned().unwrap_or(Value::Null), + "credits": usage.get("credits").cloned().unwrap_or(Value::Null), + })); + + Ok(OcrResponseData { + pages: build_pages(result), + model: model.to_string(), + document_annotation: None, + usage_info, + object: "ocr".to_string(), + extra_fields: Map::new(), + provider_native_response: Some(response_json), + }) +} + +impl OcrProviderConfig for ReductoParseV3Config { + fn supported_ocr_params(&self) -> &'static [&'static str] { + PARSE_V3_SUPPORTED_OCR_PARAMS + } + + fn transform_ocr_request( + &self, + _model: &str, + document: Value, + optional_params: Map, + ) -> Result { + let file_id = source_file_id(&document)?; + Ok(build_parse_v3_request(&file_id, optional_params)) + } + + fn transform_ocr_response( + &self, + model: &str, + response_json: Value, + ) -> Result { + transform_reducto_response(model, response_json) + } + + fn complete_url( + &self, + api_base: Option<&str>, + _model: &str, + _optional_params: &Map, + _env_lookup: &dyn Fn(&str) -> Option, + ) -> Result { + Ok(parse_url(api_base)) + } + + fn resolve_api_key( + &self, + api_key: Option<&str>, + env_lookup: &dyn Fn(&str) -> Option, + ) -> Result { + resolve_api_key(api_key, env_lookup) + } +} + +impl OcrProviderConfig for ReductoParseLegacyConfig { + fn supported_ocr_params(&self) -> &'static [&'static str] { + PARSE_LEGACY_SUPPORTED_OCR_PARAMS + } + + fn transform_ocr_request( + &self, + _model: &str, + document: Value, + optional_params: Map, + ) -> Result { + let file_id = source_file_id(&document)?; + Ok(build_parse_legacy_request(&file_id, &optional_params)) + } + + fn transform_ocr_response( + &self, + model: &str, + response_json: Value, + ) -> Result { + transform_reducto_response(model, response_json) + } + + fn complete_url( + &self, + api_base: Option<&str>, + _model: &str, + _optional_params: &Map, + _env_lookup: &dyn Fn(&str) -> Option, + ) -> Result { + Ok(parse_url(api_base)) + } + + fn resolve_api_key( + &self, + api_key: Option<&str>, + env_lookup: &dyn Fn(&str) -> Option, + ) -> Result { + resolve_api_key(api_key, env_lookup) + } +} diff --git a/litellm-rust/crates/core/src/providers/vertex_ai/ocr/transformation.rs b/litellm-rust/crates/core/src/providers/vertex_ai/ocr/transformation.rs index ee095447028..c324de8cb45 100644 --- a/litellm-rust/crates/core/src/providers/vertex_ai/ocr/transformation.rs +++ b/litellm-rust/crates/core/src/providers/vertex_ai/ocr/transformation.rs @@ -212,6 +212,7 @@ impl OcrProviderConfig for VertexAiOcrConfig { MISTRAL_OCR_CONFIG.supported_ocr_params() } + #[tracing::instrument(target = "litellm::function_trace", level = "trace", skip_all)] fn transform_ocr_request( &self, model: &str, @@ -229,6 +230,7 @@ impl OcrProviderConfig for VertexAiOcrConfig { MISTRAL_OCR_CONFIG.transform_ocr_response(model, response_json) } + #[tracing::instrument(target = "litellm::function_trace", level = "trace", skip_all)] fn complete_url( &self, api_base: Option<&str>, @@ -253,10 +255,21 @@ impl OcrProviderConfig for VertexAiOcrConfig { } impl OcrProviderConfig for VertexAiDeepSeekOcrConfig { + #[tracing::instrument(target = "litellm::function_trace", level = "trace", skip_all)] fn supported_ocr_params(&self) -> &'static [&'static str] { DEEPSEEK_SUPPORTED_OCR_PARAMS } + #[tracing::instrument(target = "litellm::function_trace", level = "trace", skip_all)] + fn map_ocr_params(&self, non_default_params: &Map) -> Map { + non_default_params + .iter() + .filter(|(name, _)| DEEPSEEK_SUPPORTED_OCR_PARAMS.contains(&name.as_str())) + .map(|(name, value)| (name.clone(), value.clone())) + .collect() + } + + #[tracing::instrument(target = "litellm::function_trace", level = "trace", skip_all)] fn transform_ocr_request( &self, model: &str, @@ -283,6 +296,7 @@ impl OcrProviderConfig for VertexAiDeepSeekOcrConfig { }) } + #[tracing::instrument(target = "litellm::function_trace", level = "trace", skip_all)] fn transform_ocr_response( &self, model: &str, @@ -335,9 +349,12 @@ impl OcrProviderConfig for VertexAiDeepSeekOcrConfig { document_annotation: object.get("document_annotation").cloned(), usage_info, object: "ocr".to_string(), + extra_fields: Map::new(), + provider_native_response: None, }) } + #[tracing::instrument(target = "litellm::function_trace", level = "trace", skip_all)] fn complete_url( &self, api_base: Option<&str>, @@ -360,6 +377,7 @@ impl OcrProviderConfig for VertexAiDeepSeekOcrConfig { #[cfg(test)] mod tests { use super::*; + use rstest::rstest; #[test] fn vertex_mistral_url_uses_project_location_and_model() { @@ -411,6 +429,22 @@ mod tests { ); } + #[rstest] + #[case::bare_model("deepseek-ocr-maas")] + #[case::namespaced_model("deepseek-ai/deepseek-ocr-maas")] + fn vertex_deepseek_request_uses_single_provider_namespace(#[case] model: &str) { + let body = VERTEX_AI_DEEPSEEK_OCR_CONFIG + .transform_ocr_request( + model, + json!({"type": "image_url", "image_url": "data:image/png;base64,AA=="}), + Map::new(), + ) + .expect("request transforms") + .data; + + assert_eq!(body["model"], "deepseek-ai/deepseek-ocr-maas"); + } + #[test] fn vertex_deepseek_response_wraps_markdown_content() { let response = VERTEX_AI_DEEPSEEK_OCR_CONFIG diff --git a/litellm-rust/crates/python-bridge/Cargo.toml b/litellm-rust/crates/python-bridge/Cargo.toml index 637e5580170..bda09a7d840 100644 --- a/litellm-rust/crates/python-bridge/Cargo.toml +++ b/litellm-rust/crates/python-bridge/Cargo.toml @@ -14,11 +14,15 @@ default = ["abi3"] abi3 = ["pyo3/abi3-py310"] extension-module = ["pyo3/extension-module"] panic-test = [] +trace-parity = [ + "dep:tracing", + "litellm-core/observability", + "litellm-ai-gateway/trace-parity", +] [dependencies] futures-util.workspace = true -tracing.workspace = true -tracing-subscriber.workspace = true +tracing = { workspace = true, optional = true } litellm-core = { workspace = true, features = ["bedrock-auth"] } litellm-ai-gateway = { workspace = true, default-features = false } litellm-python-interop.workspace = true @@ -31,6 +35,7 @@ tokio.workspace = true [dev-dependencies] criterion = "0.8.2" tokio-tungstenite.workspace = true +tracing.workspace = true [[bench]] name = "serialization" diff --git a/litellm-rust/crates/python-bridge/src/constants.rs b/litellm-rust/crates/python-bridge/src/constants.rs deleted file mode 100644 index 07b2836b838..00000000000 --- a/litellm-rust/crates/python-bridge/src/constants.rs +++ /dev/null @@ -1 +0,0 @@ -pub(crate) const FUNCTION_TRACE_TARGET: &str = "litellm::function_trace"; diff --git a/litellm-rust/crates/python-bridge/src/function_trace.rs b/litellm-rust/crates/python-bridge/src/function_trace.rs index 420d237c79d..ea7d9f4993e 100644 --- a/litellm-rust/crates/python-bridge/src/function_trace.rs +++ b/litellm-rust/crates/python-bridge/src/function_trace.rs @@ -1,216 +1,22 @@ use std::future::Future; -use std::sync::{Arc, Mutex}; +use litellm_core::observability::{FunctionTrace, FunctionTraceEvent}; use serde::Serialize; use tracing::instrument::WithSubscriber; -use tracing::span::{Attributes, Id}; -use tracing::{Dispatch, Level, Subscriber}; -use tracing_subscriber::filter::{LevelFilter, filter_fn}; -use tracing_subscriber::layer::Context; -use tracing_subscriber::prelude::*; -use tracing_subscriber::registry::LookupSpan; -use tracing_subscriber::{Layer, Registry}; - -use crate::constants::FUNCTION_TRACE_TARGET; #[derive(Serialize)] -#[serde(untagged)] -pub(crate) enum TraceResponse { - Plain(T), - Traced { - response: T, - trace: Vec, - }, +pub(crate) struct TracedResponse { + response: T, + trace: Vec, } -pub(crate) async fn trace_call( +pub(crate) async fn capture( future: impl Future>, - enabled: bool, -) -> Result, E> { - if !enabled { - return future.await.map(TraceResponse::Plain); - } +) -> Result, E> { let trace = FunctionTrace::default(); let response = future.with_subscriber(trace.dispatcher()).await?; - Ok(TraceResponse::Traced { + Ok(TracedResponse { response, trace: trace.events(), }) } - -#[derive(Clone, Debug, PartialEq, Serialize)] -pub struct FunctionTraceEvent { - pub function: &'static str, - pub depth: usize, -} - -#[derive(Clone, Default)] -pub struct FunctionTrace { - events: Arc>>, -} - -impl FunctionTrace { - pub fn dispatcher(&self) -> Dispatch { - let filter = filter_fn(|metadata| { - metadata.is_span() - && metadata.target() == FUNCTION_TRACE_TARGET - && *metadata.level() == Level::TRACE - }) - .with_max_level_hint(LevelFilter::TRACE); - Dispatch::new( - Registry::default().with( - FunctionTraceLayer { - trace: self.clone(), - } - .with_filter(filter), - ), - ) - } - - pub fn events(&self) -> Vec { - self.events - .lock() - .unwrap_or_else(|error| error.into_inner()) - .clone() - } -} - -struct FunctionTraceLayer { - trace: FunctionTrace, -} - -impl Layer for FunctionTraceLayer -where - S: Subscriber + for<'lookup> LookupSpan<'lookup>, -{ - fn on_new_span(&self, attributes: &Attributes<'_>, id: &Id, context: Context<'_, S>) { - let depth = context - .span(id) - .map(|span| span.scope().skip(1).count()) - .unwrap_or_default(); - self.trace - .events - .lock() - .unwrap_or_else(|error| error.into_inner()) - .push(FunctionTraceEvent { - function: attributes.metadata().name(), - depth, - }); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[tracing::instrument(target = "litellm::function_trace", level = "trace", skip_all)] - async fn outer() { - tokio::task::yield_now().await; - inner().await; - } - - #[tracing::instrument(target = "litellm::function_trace", level = "trace", skip_all)] - async fn inner() { - tokio::task::yield_now().await; - } - - #[tokio::test] - async fn concurrent_futures_keep_separate_traces_across_yields() { - use tracing::instrument::WithSubscriber; - - let first = FunctionTrace::default(); - let second = FunctionTrace::default(); - let outside = FunctionTrace::default(); - - async { - tokio::join!( - outer().with_subscriber(first.dispatcher()), - inner().with_subscriber(second.dispatcher()), - ); - inner().await; - } - .with_subscriber(outside.dispatcher()) - .await; - - assert_eq!( - first.events(), - vec![ - FunctionTraceEvent { - function: "outer", - depth: 0 - }, - FunctionTraceEvent { - function: "inner", - depth: 1 - }, - ], - ); - assert_eq!( - second.events(), - vec![FunctionTraceEvent { - function: "inner", - depth: 0 - }], - ); - assert_eq!( - outside.events(), - vec![FunctionTraceEvent { - function: "inner", - depth: 0 - }], - ); - } - - #[test] - fn records_matching_spans_in_creation_order() { - let trace = FunctionTrace::default(); - let dispatch = trace.dispatcher(); - - tracing::dispatcher::with_default(&dispatch, || { - let _ignored = tracing::trace_span!(target: "other", "ignored"); - let _first = tracing::trace_span!(target: FUNCTION_TRACE_TARGET, "same_name"); - let _wrong_level = tracing::debug_span!(target: FUNCTION_TRACE_TARGET, "wrong_level"); - let _second = tracing::trace_span!(target: FUNCTION_TRACE_TARGET, "same_name"); - }); - - assert_eq!( - trace.events(), - vec![ - FunctionTraceEvent { - function: "same_name", - depth: 0, - }, - FunctionTraceEvent { - function: "same_name", - depth: 0, - }, - ] - ); - } - - #[test] - fn records_matching_span_nesting_depth() { - let trace = FunctionTrace::default(); - let dispatch = trace.dispatcher(); - - tracing::dispatcher::with_default(&dispatch, || { - let outer = tracing::trace_span!(target: FUNCTION_TRACE_TARGET, "outer"); - let _outer_guard = outer.enter(); - let _inner = tracing::trace_span!(target: FUNCTION_TRACE_TARGET, "inner"); - }); - - assert_eq!( - trace.events(), - vec![ - FunctionTraceEvent { - function: "outer", - depth: 0, - }, - FunctionTraceEvent { - function: "inner", - depth: 1, - }, - ] - ); - } -} diff --git a/litellm-rust/crates/python-bridge/src/lib.rs b/litellm-rust/crates/python-bridge/src/lib.rs index 5f36a22370a..384f0be5a1b 100644 --- a/litellm-rust/crates/python-bridge/src/lib.rs +++ b/litellm-rust/crates/python-bridge/src/lib.rs @@ -1,8 +1,8 @@ -mod constants; mod diagnostics; mod errors; mod execution; -pub mod function_trace; +#[cfg(feature = "trace-parity")] +mod function_trace; mod marshal; mod routes; @@ -115,9 +115,43 @@ mod tests { .extract::>() .expect("module names should be strings") .into_iter() - .filter(|name| !name.starts_with("__")) + .filter(|name| !name.starts_with('_')) .collect(); assert_eq!(public_names, expected); + + #[cfg(not(feature = "trace-parity"))] + assert!(!module.hasattr("_trace").expect("module lookup should work")); + + #[cfg(feature = "trace-parity")] + { + let trace = module + .getattr("_trace") + .expect("trace build should expose its diagnostic namespace"); + let trace_names: Vec = trace + .cast::() + .expect("trace namespace should be a module") + .dict() + .keys() + .extract::>() + .expect("trace names should be strings") + .into_iter() + .filter(|name| !name.starts_with("__")) + .collect(); + assert_eq!( + trace_names, + [ + "ocr", + "aocr", + "transcription", + "atranscription", + "messages", + "amessages", + "chat_completions", + "achat_completions", + "gateway_messages", + ] + ); + } }); } diff --git a/litellm-rust/crates/python-bridge/src/routes/audio_transcription.rs b/litellm-rust/crates/python-bridge/src/routes/audio_transcription.rs index 10b86132be7..af60515b0e2 100644 --- a/litellm-rust/crates/python-bridge/src/routes/audio_transcription.rs +++ b/litellm-rust/crates/python-bridge/src/routes/audio_transcription.rs @@ -54,16 +54,16 @@ bridge_route! { required = { model: String, #[pyo3(from_py_with = litellm_python_interop::from_py)] - audio: Value, + audio: serde_json::Value, }, optional = { api_key: Option, api_base: Option, custom_llm_provider: Option, #[pyo3(from_py_with = litellm_python_interop::from_py)] - extra_headers: Option, + extra_headers: Option, #[pyo3(from_py_with = litellm_python_interop::from_py)] - optional_params: Option, + optional_params: Option, timeout_seconds: Option, }, prepare = prepare_transcription, diff --git a/litellm-rust/crates/python-bridge/src/routes/chat_completions.rs b/litellm-rust/crates/python-bridge/src/routes/chat_completions.rs index 68b7762cb10..08ab476005c 100644 --- a/litellm-rust/crates/python-bridge/src/routes/chat_completions.rs +++ b/litellm-rust/crates/python-bridge/src/routes/chat_completions.rs @@ -73,16 +73,16 @@ bridge_route! { required = { model: String, #[pyo3(from_py_with = litellm_python_interop::from_py)] - messages: Value, + messages: serde_json::Value, }, optional = { #[pyo3(from_py_with = litellm_python_interop::from_py)] - optional_params: Option, + optional_params: Option, api_key: Option, api_base: Option, custom_llm_provider: Option, #[pyo3(from_py_with = litellm_python_interop::from_py)] - extra_headers: Option, + extra_headers: Option, timeout_seconds: Option, }, prepare = prepare_chat_completions, diff --git a/litellm-rust/crates/python-bridge/src/routes/definition.rs b/litellm-rust/crates/python-bridge/src/routes/definition.rs index 21a7fd5a766..3285da14d5f 100644 --- a/litellm-rust/crates/python-bridge/src/routes/definition.rs +++ b/litellm-rust/crates/python-bridge/src/routes/definition.rs @@ -20,43 +20,33 @@ macro_rules! bridge_route { } #[pyfunction] - #[pyo3(signature = ($($required_name),*, $($optional_name=None,)* trace=false))] + #[pyo3(signature = ($($required_name),*, $($optional_name=None),*))] #[allow(clippy::too_many_arguments)] fn $sync_name( py: pyo3::Python<'_>, $($(#[$required_attr])* $required_name: $required_type,)* $($(#[$optional_attr])* $optional_name: $optional_type,)* - trace: bool, ) -> pyo3::PyResult> { let future = $prepare($inputs { $($required_name,)* $($optional_name),* })?; - $crate::execution::run_sync( - py, - $crate::function_trace::trace_call(future, trace), - $map_error, - ) + $crate::execution::run_sync(py, future, $map_error) } #[pyfunction] - #[pyo3(signature = ($($required_name),*, $($optional_name=None,)* trace=false))] + #[pyo3(signature = ($($required_name),*, $($optional_name=None),*))] #[allow(clippy::too_many_arguments)] fn $async_name( py: pyo3::Python<'_>, $($(#[$required_attr])* $required_name: $required_type,)* $($(#[$optional_attr])* $optional_name: $optional_type,)* - trace: bool, ) -> pyo3::PyResult> { let future = $prepare($inputs { $($required_name,)* $($optional_name),* })?; - $crate::execution::run_async( - py, - $crate::function_trace::trace_call(future, trace), - $map_error, - ) + $crate::execution::run_async(py, future, $map_error) } pub(super) fn register( @@ -67,6 +57,71 @@ macro_rules! bridge_route { $crate::routes::definition::add_function(module, pyo3::wrap_pyfunction!($async_name, module)?)?; Ok(()) } + + #[cfg(feature = "trace-parity")] + mod trace { + use pyo3::prelude::*; + use super::{$inputs, $map_error, $prepare}; + + #[pyfunction] + #[pyo3(signature = ($($required_name),*, $($optional_name=None),*))] + #[allow(clippy::too_many_arguments)] + fn $sync_name( + py: pyo3::Python<'_>, + $($(#[$required_attr])* $required_name: $required_type,)* + $($(#[$optional_attr])* $optional_name: $optional_type,)* + ) -> pyo3::PyResult> { + let future = $prepare($inputs { + $($required_name,)* + $($optional_name),* + })?; + $crate::execution::run_sync( + py, + $crate::function_trace::capture(future), + $map_error, + ) + } + + #[pyfunction] + #[pyo3(signature = ($($required_name),*, $($optional_name=None),*))] + #[allow(clippy::too_many_arguments)] + fn $async_name( + py: pyo3::Python<'_>, + $($(#[$required_attr])* $required_name: $required_type,)* + $($(#[$optional_attr])* $optional_name: $optional_type,)* + ) -> pyo3::PyResult> { + let future = $prepare($inputs { + $($required_name,)* + $($optional_name),* + })?; + $crate::execution::run_async( + py, + $crate::function_trace::capture(future), + $map_error, + ) + } + + pub(super) fn register( + module: &pyo3::Bound<'_, pyo3::types::PyModule>, + ) -> pyo3::PyResult<()> { + $crate::routes::definition::add_function( + module, + pyo3::wrap_pyfunction!($sync_name, module)?, + )?; + $crate::routes::definition::add_function( + module, + pyo3::wrap_pyfunction!($async_name, module)?, + )?; + Ok(()) + } + } + + #[cfg(feature = "trace-parity")] + pub(super) fn register_trace( + module: &pyo3::Bound<'_, pyo3::types::PyModule>, + ) -> pyo3::PyResult<()> { + trace::register(module) + } }; } @@ -130,20 +185,26 @@ mod tests { ) -> PyResult> + Send + 'static> { FUTURE_DROPPED.store(false, Ordering::SeqCst); let drop_guard = (inputs.value == "pending").then_some(DropGuard); - Ok(async move { - let _drop_guard = drop_guard; - tokio::task::yield_now().await; - match inputs.value.as_str() { - "error" => Err(Error::InvalidRequest("synthetic error".to_string())), - "map_panic" => Err(Error::InvalidRequest("panic in mapper".to_string())), - "panic" => panic!("synthetic panic"), - "pending" => { - pending::<()>().await; - unreachable!() - } - _ => Ok(inputs.value), + Ok(execute_echo(inputs, drop_guard)) + } + + #[tracing::instrument(target = "litellm::function_trace", level = "trace", skip_all)] + async fn execute_echo( + inputs: EchoInputs, + drop_guard: Option, + ) -> Result { + let _drop_guard = drop_guard; + tokio::task::yield_now().await; + match inputs.value.as_str() { + "error" => Err(Error::InvalidRequest("synthetic error".to_string())), + "map_panic" => Err(Error::InvalidRequest("panic in mapper".to_string())), + "panic" => panic!("synthetic panic"), + "pending" => { + pending::<()>().await; + unreachable!() } - }) + _ => Ok(inputs.value), + } } fn map_error(error: Error) -> PyErr { @@ -164,22 +225,22 @@ mod tests { ( "ocr", "aocr", - "(model, document, api_key=None, api_base=None, custom_llm_provider=None, extra_headers=None, optional_params=None, timeout_seconds=None, trace=False)", + "(model, document, api_key=None, api_base=None, custom_llm_provider=None, extra_headers=None, optional_params=None, timeout_seconds=None)", ), ( "transcription", "atranscription", - "(model, audio, api_key=None, api_base=None, custom_llm_provider=None, extra_headers=None, optional_params=None, timeout_seconds=None, trace=False)", + "(model, audio, api_key=None, api_base=None, custom_llm_provider=None, extra_headers=None, optional_params=None, timeout_seconds=None)", ), ( "messages", "amessages", - "(model, body, api_key=None, api_base=None, custom_llm_provider=None, extra_headers=None, timeout_seconds=None, trace=False)", + "(model, body, api_key=None, api_base=None, custom_llm_provider=None, extra_headers=None, timeout_seconds=None)", ), ( "chat_completions", "achat_completions", - "(model, messages, optional_params=None, api_key=None, api_base=None, custom_llm_provider=None, extra_headers=None, timeout_seconds=None, trace=False)", + "(model, messages, optional_params=None, api_key=None, api_base=None, custom_llm_provider=None, extra_headers=None, timeout_seconds=None)", ), ]; @@ -411,6 +472,32 @@ asyncio.run(exercise()) }); } + #[cfg(feature = "trace-parity")] + #[test] + fn diagnostic_route_returns_the_response_and_filtered_trace() { + Python::initialize(); + Python::attach(|py| { + let module = PyModule::new(py, "synthetic").expect("module should be created"); + synthetic::register_trace(&module).expect("trace routes should register"); + let locals = PyDict::new(py); + locals + .set_item("routes", &module) + .expect("module should enter Python locals"); + let code = CString::new( + r#" +result = routes.echo("traced") +assert result == { + "response": "traced", + "trace": [{"function": "execute_echo", "depth": 0}], +} +"#, + ) + .expect("Python source should not contain null bytes"); + py.run(&code, Some(&locals), Some(&locals)) + .expect("diagnostic route should return its response and trace"); + }); + } + #[test] fn route_registration_rejects_duplicate_python_names() { Python::initialize(); diff --git a/litellm-rust/crates/python-bridge/src/routes/gateway_messages.rs b/litellm-rust/crates/python-bridge/src/routes/gateway_messages.rs new file mode 100644 index 00000000000..97ff93f299a --- /dev/null +++ b/litellm-rust/crates/python-bridge/src/routes/gateway_messages.rs @@ -0,0 +1,29 @@ +use pyo3::prelude::*; +use serde_json::Value; + +use crate::errors::core_error_to_pyerr; + +#[pyfunction] +fn gateway_messages<'py>( + py: Python<'py>, + model_alias: String, + provider_model: String, + api_base: String, + #[pyo3(from_py_with = litellm_python_interop::from_py)] body: Value, +) -> PyResult> { + let future = litellm_ai_gateway::trace_parity::messages_request( + model_alias, + provider_model, + api_base, + body, + ); + crate::execution::run_async( + py, + crate::function_trace::capture(future), + core_error_to_pyerr, + ) +} + +pub(super) fn register_trace(module: &Bound<'_, PyModule>) -> PyResult<()> { + super::definition::add_function(module, wrap_pyfunction!(gateway_messages, module)?) +} diff --git a/litellm-rust/crates/python-bridge/src/routes/messages.rs b/litellm-rust/crates/python-bridge/src/routes/messages.rs index 2bb64a7a763..f69b5e9251d 100644 --- a/litellm-rust/crates/python-bridge/src/routes/messages.rs +++ b/litellm-rust/crates/python-bridge/src/routes/messages.rs @@ -50,14 +50,14 @@ bridge_route! { required = { model: String, #[pyo3(from_py_with = litellm_python_interop::from_py)] - body: Value, + body: serde_json::Value, }, optional = { api_key: Option, api_base: Option, custom_llm_provider: Option, #[pyo3(from_py_with = litellm_python_interop::from_py)] - extra_headers: Option, + extra_headers: Option, timeout_seconds: Option, }, prepare = prepare_messages, diff --git a/litellm-rust/crates/python-bridge/src/routes/mod.rs b/litellm-rust/crates/python-bridge/src/routes/mod.rs index bf611c26d44..7e81f2ffe9b 100644 --- a/litellm-rust/crates/python-bridge/src/routes/mod.rs +++ b/litellm-rust/crates/python-bridge/src/routes/mod.rs @@ -3,6 +3,9 @@ use pyo3::prelude::*; #[macro_use] mod definition; +#[cfg(feature = "trace-parity")] +mod gateway_messages; + mod audio_transcription; mod chat_completions; mod messages; @@ -12,5 +15,16 @@ pub(crate) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { ocr::register(module)?; audio_transcription::register(module)?; messages::register(module)?; - chat_completions::register(module) + chat_completions::register(module)?; + #[cfg(feature = "trace-parity")] + { + let trace = PyModule::new(module.py(), "_trace")?; + ocr::register_trace(&trace)?; + audio_transcription::register_trace(&trace)?; + messages::register_trace(&trace)?; + chat_completions::register_trace(&trace)?; + gateway_messages::register_trace(&trace)?; + module.add_submodule(&trace)?; + } + Ok(()) } diff --git a/litellm-rust/crates/python-bridge/src/routes/ocr.rs b/litellm-rust/crates/python-bridge/src/routes/ocr.rs index 5cc8804238b..cc2f8e43cea 100644 --- a/litellm-rust/crates/python-bridge/src/routes/ocr.rs +++ b/litellm-rust/crates/python-bridge/src/routes/ocr.rs @@ -56,16 +56,16 @@ bridge_route! { required = { model: String, #[pyo3(from_py_with = litellm_python_interop::from_py)] - document: Value, + document: serde_json::Value, }, optional = { api_key: Option, api_base: Option, custom_llm_provider: Option, #[pyo3(from_py_with = litellm_python_interop::from_py)] - extra_headers: Option, + extra_headers: Option, #[pyo3(from_py_with = litellm_python_interop::from_py)] - optional_params: Option, + optional_params: Option, timeout_seconds: Option, }, prepare = prepare_ocr, diff --git a/litellm-rust/crates/python-bridge/src/routes/runtime.rs b/litellm-rust/crates/python-bridge/src/routes/runtime.rs deleted file mode 100644 index 87a0c3e0104..00000000000 --- a/litellm-rust/crates/python-bridge/src/routes/runtime.rs +++ /dev/null @@ -1,423 +0,0 @@ -use std::future::Future; -use std::panic::AssertUnwindSafe; -use std::time::Duration; - -use futures_util::FutureExt; -use litellm_core::error::Error; -use litellm_python_interop::{Pythonized, panic_to_pyerr, release_gil}; -use pyo3::exceptions::PyRuntimeError; -use pyo3::prelude::*; -use serde::Serialize; -use tokio::runtime::{Handle, Runtime}; -use tokio::time::{self, MissedTickBehavior}; - -pub(super) fn run_sync( - py: Python<'_>, - future: F, - map_error: fn(Error) -> PyErr, -) -> PyResult> -where - T: Serialize + Send + 'static, - F: Future> + Send + 'static, -{ - run_sync_on( - py, - pyo3_async_runtimes::tokio::get_runtime(), - future, - map_error, - ) -} - -fn run_sync_on( - py: Python<'_>, - runtime: &Runtime, - future: F, - map_error: fn(Error) -> PyErr, -) -> PyResult> -where - T: Serialize + Send + 'static, - F: Future> + Send + 'static, -{ - if Handle::try_current().is_ok() { - return Err(PyRuntimeError::new_err( - "synchronous native routes cannot run from a Tokio context; use the async route", - )); - } - - let result = release_gil(py, move || runtime.block_on(wait_for_sync_result(future)))?; - let result = map_core_result(result, map_error)?; - Pythonized(result).into_pyobject(py).map(Bound::unbind) -} - -pub(super) fn run_async( - py: Python<'_>, - future: F, - map_error: fn(Error) -> PyErr, -) -> PyResult> -where - T: Serialize + Send + 'static, - F: Future> + Send + 'static, -{ - pyo3_async_runtimes::tokio::future_into_py(py, async move { - let result = catch_route_panic(future).await?; - let result = map_core_result(result, map_error)?; - Ok(Pythonized(result)) - }) -} - -fn map_core_result(result: Result, map_error: fn(Error) -> PyErr) -> PyResult { - match result { - Ok(value) => Ok(value), - Err(error) => Err( - std::panic::catch_unwind(AssertUnwindSafe(|| map_error(error))) - .map_err(panic_to_pyerr)?, - ), - } -} - -async fn catch_route_panic(future: F) -> PyResult> -where - F: Future>, -{ - AssertUnwindSafe(future) - .catch_unwind() - .await - .map_err(panic_to_pyerr) -} - -async fn wait_for_sync_result(future: F) -> PyResult> -where - F: Future>, -{ - let future = catch_route_panic(future); - tokio::pin!(future); - - let signal_interval = Duration::from_millis(50); - let mut signal_checks = - time::interval_at(time::Instant::now() + signal_interval, signal_interval); - signal_checks.set_missed_tick_behavior(MissedTickBehavior::Delay); - loop { - tokio::select! { - result = &mut future => return result, - _ = signal_checks.tick() => Python::attach(|py| py.check_signals())?, - } - } -} - -#[cfg(test)] -mod tests { - use std::ffi::CString; - use std::future::poll_fn; - use std::sync::atomic::{AtomicUsize, Ordering}; - use std::sync::{Arc, mpsc}; - use std::task::Poll; - use std::thread; - use std::time::Instant; - - use pyo3::panic::PanicException; - use pyo3::types::{PyDict, PyModule}; - use serde::Serializer; - use tokio::runtime::Builder; - - use super::*; - - fn runtime_error(error: Error) -> PyErr { - PyRuntimeError::new_err(error.to_string()) - } - - fn panicking_error_mapper(_error: Error) -> PyErr { - panic!("error mapper panicked") - } - - struct PanickingOutput; - - static ASYNC_PROBE_COMPLETED: AtomicUsize = AtomicUsize::new(0); - - impl Serialize for PanickingOutput { - fn serialize(&self, _serializer: S) -> Result - where - S: Serializer, - { - panic!("serializer panicked") - } - } - - #[pyfunction] - fn async_serialization_panic(py: Python<'_>) -> PyResult> { - run_async(py, async { Ok(PanickingOutput) }, runtime_error) - } - - #[pyfunction] - fn async_runtime_probe(py: Python<'_>) -> PyResult> { - run_async( - py, - async { - ASYNC_PROBE_COMPLETED.fetch_add(1, Ordering::SeqCst); - Ok(true) - }, - runtime_error, - ) - } - - #[pyfunction] - fn runtime_worker_count() -> usize { - pyo3_async_runtimes::tokio::get_runtime() - .metrics() - .num_workers() - } - - #[pyfunction] - fn runtime_is_responsive(_py: Python<'_>, expected_completions: usize) -> bool { - let completion_deadline = Instant::now() + Duration::from_secs(2); - while ASYNC_PROBE_COMPLETED.load(Ordering::SeqCst) < expected_completions { - if Instant::now() >= completion_deadline { - return false; - } - thread::sleep(Duration::from_millis(1)); - } - - let (heartbeat_tx, heartbeat_rx) = mpsc::sync_channel(1); - pyo3_async_runtimes::tokio::get_runtime().spawn(async move { - let _ = heartbeat_tx.send(()); - }); - heartbeat_rx.recv_timeout(Duration::from_secs(2)).is_ok() - } - - fn extract_bool(py: Python<'_>, result: PyResult>) -> bool { - result - .expect("route should complete") - .bind(py) - .extract() - .expect("result should convert") - } - - #[test] - fn sync_runner_polls_future_on_the_caller_thread() { - Python::initialize(); - Python::attach(|py| { - let caller_thread = std::thread::current().id(); - let result = run_sync( - py, - async move { Ok(std::thread::current().id() == caller_thread) }, - runtime_error, - ); - - assert!(extract_bool(py, result)); - }); - } - - #[test] - fn sync_runner_releases_gil_while_waiting() { - Python::initialize(); - Python::attach(|py| { - let result = run_sync( - py, - async { - let gil_acquired = tokio::time::timeout( - Duration::from_secs(2), - tokio::task::spawn_blocking(|| Python::attach(|_| true)), - ) - .await; - Ok(matches!(gil_acquired, Ok(Ok(true)))) - }, - runtime_error, - ); - - assert!(extract_bool(py, result)); - }); - } - - #[test] - fn sync_runner_rejects_calls_from_a_tokio_context() { - Python::initialize(); - let runtime = Builder::new_current_thread() - .enable_all() - .build() - .expect("runtime should build"); - - let error = runtime.block_on(async { - Python::attach(|py| { - run_sync::(py, async { Ok(true) }, runtime_error) - .expect_err("sync route should reject a nested Tokio runtime") - }) - }); - - assert_eq!( - error.to_string(), - "RuntimeError: synchronous native routes cannot run from a Tokio context; use the async route" - ); - } - - #[test] - fn sync_runner_can_drive_a_current_thread_runtime() { - Python::initialize(); - let runtime = Builder::new_current_thread() - .enable_all() - .build() - .expect("runtime should build"); - Python::attach(|py| { - let result = run_sync_on( - py, - &runtime, - async { - tokio::task::yield_now().await; - Ok(true) - }, - runtime_error, - ); - assert!(extract_bool(py, result)); - }); - } - - #[test] - fn sync_runner_maps_a_panicked_future() { - Python::initialize(); - Python::attach(|py| { - let error = run_sync::( - py, - poll_fn(|_| -> Poll> { panic!("route future panicked") }), - runtime_error, - ) - .expect_err("panicked route should become a Python exception"); - - assert!(error.is_instance_of::(py)); - assert_eq!(error.to_string(), "PanicException: route future panicked"); - }); - } - - #[test] - fn sync_runner_maps_a_panicked_error_mapper() { - Python::initialize(); - Python::attach(|py| { - let error = run_sync::( - py, - async { Err(Error::InvalidRequest("invalid".to_string())) }, - panicking_error_mapper, - ) - .expect_err("panicked mapper should become a Python exception"); - - assert!(error.is_instance_of::(py)); - assert_eq!(error.to_string(), "PanicException: error mapper panicked"); - }); - } - - #[test] - fn sync_runner_surfaces_serializer_panics() { - Python::initialize(); - Python::attach(|py| { - let error = run_sync(py, async { Ok(PanickingOutput) }, runtime_error) - .expect_err("serializer panic should become a Python exception"); - - assert!(error.is_instance_of::(py)); - assert_eq!(error.to_string(), "PanicException: serializer panicked"); - }); - } - - #[test] - fn sync_runner_supports_concurrent_callers_on_the_shared_runtime() { - Python::initialize(); - let barrier = Arc::new(tokio::sync::Barrier::new(2)); - let callers: Vec<_> = (0..2) - .map(|_| { - let barrier = Arc::clone(&barrier); - thread::spawn(move || { - Python::attach(|py| { - extract_bool( - py, - run_sync( - py, - async move { - Ok(tokio::time::timeout(Duration::from_secs(2), barrier.wait()) - .await - .is_ok()) - }, - runtime_error, - ), - ) - }) - }) - }) - .collect(); - let results: Vec<_> = callers - .into_iter() - .map(|caller| caller.join().expect("caller should not panic")) - .collect(); - - assert_eq!(results, vec![true, true]); - } - - #[test] - fn async_runner_surfaces_serializer_panics() { - Python::initialize(); - Python::attach(|py| { - let module = PyModule::new(py, "runtime").expect("module should be created"); - module - .add_function( - wrap_pyfunction!(async_serialization_panic, &module) - .expect("function should wrap"), - ) - .expect("function should register"); - let locals = PyDict::new(py); - locals - .set_item("runtime", &module) - .expect("module should enter Python locals"); - let code = CString::new( - r#" -import asyncio - -async def exercise(): - try: - await runtime.async_serialization_panic() - except BaseException as error: - assert type(error).__name__ == "PanicException" - assert str(error) == "serializer panicked" - else: - raise AssertionError("serializer panic was not raised") - -asyncio.run(exercise()) -"#, - ) - .expect("Python source should not contain null bytes"); - py.run(&code, Some(&locals), Some(&locals)) - .expect("serializer panic should reach the Python awaiter"); - }); - } - - #[test] - fn async_result_delivery_does_not_stall_tokio_workers() { - Python::initialize(); - ASYNC_PROBE_COMPLETED.store(0, Ordering::SeqCst); - Python::attach(|py| { - let module = PyModule::new(py, "runtime").expect("module should be created"); - for function in [ - wrap_pyfunction!(async_runtime_probe, &module).expect("function should wrap"), - wrap_pyfunction!(runtime_worker_count, &module).expect("function should wrap"), - wrap_pyfunction!(runtime_is_responsive, &module).expect("function should wrap"), - ] { - module - .add_function(function) - .expect("function should register"); - } - let locals = PyDict::new(py); - locals - .set_item("runtime", &module) - .expect("module should enter Python locals"); - let code = CString::new( - r#" -import asyncio - -async def exercise(): - worker_count = runtime.runtime_worker_count() - awaitables = [runtime.async_runtime_probe() for _ in range(worker_count)] - assert runtime.runtime_is_responsive(worker_count) - assert await asyncio.gather(*awaitables) == [True] * worker_count - -asyncio.run(exercise()) -"#, - ) - .expect("Python source should not contain null bytes"); - py.run(&code, Some(&locals), Some(&locals)) - .expect("result delivery should leave Tokio workers responsive"); - }); - } -} diff --git a/litellm/rust_bridge/__init__.py b/litellm/rust_bridge/__init__.py index e6d8ffef48c..9e8558bbf7d 100644 --- a/litellm/rust_bridge/__init__.py +++ b/litellm/rust_bridge/__init__.py @@ -4,6 +4,7 @@ from litellm.rust_bridge.configuration import use_litellm_rust from litellm.rust_bridge.loader import ( get_native_bridge, native_bridge_available, + reset_native_bridge_cache, ) -__all__ = ["get_native_bridge", "native_bridge_available", "use_litellm_rust"] +__all__ = ["get_native_bridge", "native_bridge_available", "reset_native_bridge_cache", "use_litellm_rust"] diff --git a/litellm/rust_bridge/loader.py b/litellm/rust_bridge/loader.py index 1c11d6435d8..022c38f5a85 100644 --- a/litellm/rust_bridge/loader.py +++ b/litellm/rust_bridge/loader.py @@ -24,6 +24,12 @@ def get_native_bridge() -> ModuleType | None: return _native +def reset_native_bridge_cache() -> None: + """Forget the cached extension so the next lookup reimports it from disk.""" + global _cached_bridge + _cached_bridge = _BRIDGE_SENTINEL + + def native_bridge_available() -> bool: """Whether the packaged Rust extension is importable.""" return get_native_bridge() is not None diff --git a/tests/rust-python-harness/AGENTS.md b/tests/rust-python-harness/AGENTS.md index e9b17027ddc..017668d4289 100644 --- a/tests/rust-python-harness/AGENTS.md +++ b/tests/rust-python-harness/AGENTS.md @@ -3,42 +3,75 @@ ```text tests/rust-python-harness/ ├── __main__.py +├── cli/ +│ ├── __init__.py +│ ├── catalog.py +│ └── commands.py │ ├── strategies/ │ ├── e2e_parity/ -│ │ ├── runner.py +│ │ ├── __init__.py +│ │ ├── reporting.py │ │ ├── sdk/ -│ │ │ ├── ocr/ -│ │ │ ├── messages/ -│ │ │ ├── chat_completions/ -│ │ │ └── responses/ -│ │ └── gateway/ +│ │ │ └── ocr/ │ │ │ ├── trace_parity/ -│ │ ├── runner.py -│ │ ├── sdk/ -│ │ └── gateway/ +│ │ ├── __init__.py +│ │ ├── models.py +│ │ ├── reporting.py +│ │ └── sdk/ +│ │ ├── chat_completions/ +│ │ ├── messages/ +│ │ ├── ocr/ +│ │ └── transcription/ │ │ -│ └── unit_tests/ -│ ├── runner.py -│ ├── mapping_validator.py -│ ├── python_runner.py -│ └── rust_runner.py +│ ├── unit_tests_mapping/ +│ │ ├── __init__.py +│ │ ├── contracts.py +│ │ ├── cases/ +│ │ │ └── ocr.py +│ │ ├── mapping_report.py +│ │ ├── mappings.py +│ │ ├── mapping_validator.py +│ │ ├── reporting.py +│ │ └── runner.py +│ │ +│ ├── unit_tests_parity/ +│ │ ├── __init__.py +│ │ ├── reporting.py +│ │ └── runner.py +│ │ +│ └── unit_tests_rust/ +│ ├── __init__.py +│ ├── reporting.py +│ └── runner.py │ └── shared/ ├── parity/ ├── tracing/ - └── reporting/ + ├── reporting/ + │ └── strategy.py + └── unit_runners/ + └── suite_runner.py ``` +- A strategy is a folder under `strategies/` with a one-line `AGENTS.md` and an `__init__.py` exporting exactly one `STRATEGY: StrategyDefinition`; its id must equal the folder name +- `shared/reporting/strategy.py` is the contract: runnable module/suite specs, not-implemented/skipped specs, the runner protocol, and `StrategyDefinition` +- Every `STRATEGY` explicitly classifies every SDK function; surface-aware strategies declare their surfaces and classify the complete surface-by-function matrix - Run locally only; no CI integration -- `__main__.py` selects strategies and combines their reports; each strategy also runs independently +- `python -m tests.rust-python-harness run |all` runs the selected strategy; `--function` is common, while each strategy exposes only its supported options +- Examples: `run e2e_parity --surface sdk --function ocr`, `run unit_tests_parity --function ocr --pytest-arg=-x`, or `run all --function ocr` +- `cli/catalog.py` discovers strategies, validates their Python definitions, and orders them; `cli/__init__.py` builds the Click command tree; `cli/commands.py` runs selected cases - `e2e_parity/` compares SDK objects, exceptions, callbacks, and streams, or gateway HTTP responses -- `trace_parity/` compares mapped operations, call counts, and required execution ordering -- E2E and trace runners share orchestration across `sdk/` and `gateway/`; surface-specific execution lives in those folders -- `unit_tests/runner.py` combines mapping validation, Python test runs, and native Rust test runs -- `mapping_validator.py` matches Python/Rust tests by agreed names or annotations and reports missing or ambiguous counterparts -- `python_runner.py` runs existing Python tests with Rust disabled and enabled in separate processes, verifies backend selection, and compares results -- `rust_runner.py` runs Cargo tests; native Rust unit tests stay beside their implementation -- `shared/` contains reusable parity, tracing, and reporting machinery +- `trace_parity/` compares mapped operations, call counts, and required execution ordering; before running it rebuilds the native bridge with the `trace-parity` feature whenever `litellm-rust` sources are newer than the installed extension (`shared/native_build.py`) +- E2E and trace strategies load their registered module cases and run surface-specific execution from their folders +- `unit_tests_mapping/contracts.py` owns typed harness-side mapping contracts, per-function contracts live below `cases/`, and `mappings.py` exports the registry; live test discovery derives unmapped Python and Rust-only tests without an exhaustive manifest +- `unit_tests_mapping/runner.py` validates confirmed mappings against the live Python and Rust inventories and attaches the derived status report +- `unit_tests_parity/runner.py` runs each contract's `unit_parity_scope` with `LITELLM_RUST=0` and `LITELLM_RUST=1` in separate processes and requires matching outcomes, including failures; exclusions require a reason in the contract +- `unit_tests_rust/runner.py` runs each contract's focused Cargo test suite; native Rust unit tests stay beside their implementation +- `shared/unit_runners/suite_runner.py` runs typed suites registered in code with nodeids of the form `suite:::` +- Every strategy declares its report sections and presentation in its own `reporting.py`; shared reporting code only provides reusable models and cell-formatting primitives +- `shared/` contains reusable parity, tracing, reporting primitives, and unit-runner machinery - Keep fixtures with their owning API and existing Python tests in their current locations +- Each strategy folder carries an `AGENTS.md` one-liner stating what it should be doing +- Run the harness's own checks with `uv run pytest -o consider_namespace_packages=true tests/rust-python-harness/shared tests/rust-python-harness/cli tests/rust-python-harness/strategies/unit_tests_mapping tests/rust-python-harness/strategies/unit_tests_parity tests/rust-python-harness/strategies/unit_tests_rust tests/test_rust_python_harness.py -q` diff --git a/tests/rust-python-harness/README.md b/tests/rust-python-harness/README.md deleted file mode 100644 index 77df4a24dd3..00000000000 --- a/tests/rust-python-harness/README.md +++ /dev/null @@ -1,105 +0,0 @@ -# Rust/Python migration harness - -This local harness follows [the agreed structure](AGENTS.md). The root command selects strategies and combines their reports. Each strategy has an independent entry point - -```text -strategies/ - e2e_parity/runner.py - sdk/ocr/fixtures/ - sdk/messages/ - sdk/chat_completions/ - sdk/responses/ - gateway/ - existing_e2e_test_sdk/runner.py - trace_parity/runner.py - sdk/ - gateway/ - unit_tests/ - runner.py - mapping_validator.py - python_runner.py - rust_runner.py -shared/ - parity/ - tracing/ - reporting/ -``` - -## Run locally - -```bash -uv run python -m tests.rust-python-harness --list -uv run python -m tests.rust-python-harness --function ocr --plain -uv run python -m tests.rust-python-harness --strategy e2e_parity --surface sdk --function ocr --plain -uv run python -m tests.rust-python-harness.strategies.e2e_parity.runner --function ocr --plain -uv run python -m tests.rust-python-harness.strategies.trace_parity.runner --plain -uv run python -m tests.rust-python-harness.strategies.unit_tests.runner --plain -uv run python -m tests.rust-python-harness.strategies.existing_e2e_test_sdk.runner --function transcription --plain -``` - -Use `--interactive` for strategy and function selection, `--pytest-arg=-x` to stop pytest on its first failure, and `--coverage` to write Python coverage under `target/rust-python-harness/`. The harness enables pytest namespace-package discovery only for its own invocations - -This harness has no CI execution. A configured test that fails or disappears makes the command fail. An unconfigured strategy cell remains planned and contributes no passing evidence. Interruptions and collection errors stop execution; ordinary test failures remain in the combined report while later strategies run - -## Strategy responsibilities - -E2E parity compares SDK objects, exceptions, callbacks, streams, and provider requests. Gateway tests compare HTTP responses. Both surfaces use the same strategy runner and keep execution details and fixtures in their own folders. OCR has recorded sync/async SDK coverage; the existing Messages and Responses bridge checks remain partial - -Trace parity compares operation names through an explicit Python/Rust mapping, call counts, and required completion-before-start ordering with `shared/tracing/compare.py`. Surface tests supply captured operation intervals. No production trace instrumentation or trace case is configured yet - -Unit testing combines test mapping validation, separate Python processes with Rust disabled and enabled, backend verification, result comparison, and native Cargo tests. Native tests stay beside their Rust implementation. Existing Python tests stay at their original paths. No complete Python/native unit mapping is configured yet, so these cells remain planned - -The existing E2E SDK strategy retains the live provider tests configured upstream. It runs OCR, Chat Completions, and Transcription checks from their existing paths and reports them separately from parity tests. These tests require provider credentials - -## Configure cases - -Each strategy has a `strategy.json`. Its `functions` object defines SDK cases for OCR, Messages, Responses, Count Tokens, Chat Completions, and Transcription. E2E and trace manifests also accept a `gateway` object keyed by API name. A case has `coverage`, `selectors`, and an optional `note` - -```json -{ - "coverage": "partial", - "selectors": ["tests/rust-python-harness/strategies/e2e_parity/sdk/ocr/test_sdk_parity.py"] -} -``` - -Selectors use pytest file or node syntax. A selector ending in `/` includes tests recursively from that directory - -Use `planned` with no selectors until an executable contract exists, `partial` for incomplete coverage, `complete` for the full contract, and `not_applicable` when a strategy does not apply. The dashboard shows passing evidence separately from coverage completeness and LOC coverage - -Unit cases use `unit_suite` instead of `selectors`, pointing to a repository-relative JSON file with this shape: - -```json -{ - "python_selectors": ["tests/test_api.py::test_decode"], - "cargo_manifest": "litellm-rust/Cargo.toml", - "cargo_package": "litellm-core", - "cargo_filter": "ocr::", - "backend": { - "environment_variable": "LITELLM_USE_RUST_OCR", - "probe": "tests.rust-python-harness.strategies.unit_tests.python_runner:ocr_backend" - }, - "mappings": [{"python": "tests/test_api.py::test_decode", "rust": "ocr::test_decode"}] -} -``` - -Names match automatically when the collected Python and Rust test names agree. Explicit `mappings` handle different names, class names, and parametrized cases. Missing or ambiguous counterparts fail validation in either direction. The Cargo filter must select the same behavior as the Python selectors - -The backend probe returns `python` or `rust` and runs at startup and before every test call, after fixtures have run. The OCR probe verifies the dispatch flag and native extension availability. Surface tests must also assert that calls reach their intended implementation to catch per-call fallback. Python outcomes must agree, and failed runs remain failures even if both backends fail identically - -## OCR fixtures - -Fixtures, provider configuration, input strategies, and recording commands live in [the OCR package](strategies/e2e_parity/sdk/ocr/fixtures/README.md). Record with provider credentials: - -```bash -uv run python -m tests.rust-python-harness.strategies.e2e_parity.sdk.ocr.fixtures.record --examples 1000 -``` - -`LITELLM_OCR_FIXTURE_DIR` and `--fixture-dir` override the default directory. Shared recording, replay, comparison, streaming, and cassette persistence live in `shared/parity/` - -Run the harness's own checks locally: - -```bash -uv run pytest -o consider_namespace_packages=true tests/rust-python-harness/shared tests/rust-python-harness/strategies/unit_tests tests/test_rust_python_harness.py -q -``` - -Existing OCR parity gaps remain visible: invalid-model provider errors differ, Reducto lacks a native contract, and the expanded Azure corpus exposes duplicate Content-Type headers. Moving the harness does not change provider responses or weaken assertions diff --git a/tests/rust-python-harness/__init__.py b/tests/rust-python-harness/__init__.py index 70362674d2b..448e24de03b 100644 --- a/tests/rust-python-harness/__init__.py +++ b/tests/rust-python-harness/__init__.py @@ -1,5 +1,4 @@ -"""Interactive Rust/Python SDK parity test harness.""" +from .cli import main +from .cli.catalog import load_catalog -from .catalog import load_catalog - -__all__ = ["load_catalog"] +__all__ = ["load_catalog", "main"] diff --git a/tests/rust-python-harness/catalog.py b/tests/rust-python-harness/catalog.py deleted file mode 100644 index f40fd5fc6b0..00000000000 --- a/tests/rust-python-harness/catalog.py +++ /dev/null @@ -1,75 +0,0 @@ -from __future__ import annotations - -import json -from pathlib import Path -from typing import Final - -from pydantic import BaseModel, ConfigDict, ValidationError - -from .shared.reporting.models import Coverage, HarnessCase, SDK_FUNCTIONS, Strategy - -STRATEGIES_ROOT: Final = Path(__file__).parent / "strategies" - - -class CaseSpec(BaseModel): - model_config = ConfigDict(frozen=True, extra="forbid") - - coverage: Coverage - selectors: tuple[str, ...] = () - note: str = "" - unit_suite: str | None = None - - -class StrategySpec(BaseModel): - model_config = ConfigDict(frozen=True, extra="forbid") - - order: int - id: str - label: str - description: str - functions: dict[str, CaseSpec] - gateway: dict[str, CaseSpec] = {} - - -def _load_strategy(source: Path) -> Strategy: - data: Final = StrategySpec.model_validate_json(source.read_text(encoding="utf-8")) - if set(data.functions) != set(SDK_FUNCTIONS): - raise ValueError(f"{source}: functions must exactly match {SDK_FUNCTIONS}") - cases: Final = tuple( - HarnessCase( - strategy_id=data.id, - strategy_label=data.label, - sdk_function=name, - coverage=case.coverage, - selectors=case.selectors, - note=case.note, - surface=surface, - unit_suite=case.unit_suite, - ) - for surface, functions in (("sdk", data.functions), ("gateway", data.gateway)) - for name in (SDK_FUNCTIONS if surface == "sdk" else functions) - for case in (functions[name],) - ) - for case in cases: - if case.coverage in {Coverage.PLANNED, Coverage.NOT_APPLICABLE} and (case.selectors or case.unit_suite): - raise ValueError(f"{source}: {case.coverage.value} case {case.key} cannot configure tests") - if any(not selector.strip() for selector in case.selectors): - raise ValueError(f"{source}: empty selector in {case.key}") - if data.id == "unit_tests" and case.selectors: - raise ValueError(f"{source}: unit_tests must configure unit_suite instead of pytest selectors") - if data.id != "unit_tests" and case.unit_suite: - raise ValueError(f"{source}: unit_suite is only valid for unit_tests") - return Strategy(data.order, data.id, data.label, data.description, source.parent, cases) - - -def load_catalog(root: Path = STRATEGIES_ROOT) -> tuple[Strategy, ...]: - sources: Final = tuple(sorted(root.glob("*/strategy.json"))) - if not sources: - raise ValueError(f"No strategy manifests found below {root}") - try: - strategies: Final = tuple(sorted((_load_strategy(source) for source in sources), key=lambda item: item.order)) - except (ValidationError, json.JSONDecodeError) as error: - raise ValueError(str(error)) from error - if len({strategy.id for strategy in strategies}) != len(strategies): - raise ValueError(f"Duplicate strategy id in {root}") - return strategies diff --git a/tests/rust-python-harness/cli.py b/tests/rust-python-harness/cli.py deleted file mode 100644 index d266a12ce92..00000000000 --- a/tests/rust-python-harness/cli.py +++ /dev/null @@ -1,245 +0,0 @@ -from __future__ import annotations - -import argparse -import importlib.util -from collections.abc import Sequence -from pathlib import Path - -from .catalog import load_catalog -from .shared.reporting.models import SDK_FUNCTIONS, HarnessCase, Strategy -from .shared.reporting.orchestration import StrategyRunner, run_strategies -from .shared.reporting.ui import make_dashboard -from .strategies.e2e_parity.runner import run as run_e2e -from .strategies.existing_e2e_test_sdk.runner import run as run_existing -from .strategies.trace_parity.runner import run as run_trace -from .strategies.unit_tests.mapping_validator import FunctionReport, build_function_report -from .strategies.unit_tests.runner import run as run_units - -REPO_ROOT = Path(__file__).resolve().parents[2] -COVERAGE_ROOT = REPO_ROOT / "target" / "rust-python-harness" - - -def _parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser( - prog="rust-python-harness", - description="Run Rust/Python parity tests with a live strategy-by-SDK-function dashboard.", - ) - parser.add_argument( - "-i", - "--interactive", - action="store_true", - help="pick strategies and SDK functions in a guided terminal menu", - ) - parser.add_argument( - "--list", action="store_true", help="show the catalog without running tests" - ) - parser.add_argument( - "--strategy", - action="append", - default=[], - metavar="ID", - help="run only this strategy", - ) - parser.add_argument( - "--function", - action="append", - default=[], - dest="sdk_functions", - choices=SDK_FUNCTIONS, - help="run only this SDK function", - ) - parser.add_argument("--surface", choices=("sdk", "gateway"), help="run only this API surface") - parser.add_argument( - "--validate-ledger", - action="store_true", - help=( - "report Python<->Rust test-parity ledger gaps and drift instead of " - "running the dashboard; narrow with --function" - ), - ) - parser.add_argument( - "--plain", - action="store_true", - help="disable the interactive terminal dashboard", - ) - parser.add_argument( - "--coverage", - action="store_true", - help="write Python reference LOC reports (HTML, JSON, and XML)", - ) - parser.add_argument( - "--pytest-arg", - action="append", - default=[], - metavar="ARG", - help="append an argument to pytest (repeatable, for example --pytest-arg=-x)", - ) - return parser - - -def _coverage_pytest_args(output_root: Path = COVERAGE_ROOT) -> tuple[str, ...]: - output_root.mkdir(parents=True, exist_ok=True) - return ( - "--cov=litellm", - "--cov-context=test", - f"--cov-report=json:{output_root / 'python.json'}", - f"--cov-report=xml:{output_root / 'python.xml'}", - f"--cov-report=html:{output_root / 'python-html'}", - ) - - -def _pick_values( - title: str, options: Sequence[tuple[str, str]], input_fn=input -) -> set[str]: - print(f"\n{title} (Enter = all)") - for index, (value, label) in enumerate(options, start=1): - print(f" {index:>2}. {label} [{value}]") - while True: - answer = input_fn("Choose numbers, comma-separated: ").strip() - if not answer: - return set() - try: - indexes = {int(part.strip()) for part in answer.split(",")} - except ValueError: - print("Please enter numbers separated by commas.") - continue - if indexes and all(1 <= index <= len(options) for index in indexes): - return {options[index - 1][0] for index in indexes} - print(f"Choose values from 1 to {len(options)}.") - - -def _interactive_filters(strategies: Sequence[Strategy]) -> tuple[set[str], set[str]]: - strategy_ids = _pick_values( - "Testing strategies", [(strategy.id, strategy.label) for strategy in strategies] - ) - sdk_functions = _pick_values( - "SDK functions", - [(name, name) for name in SDK_FUNCTIONS], - ) - return strategy_ids, sdk_functions - - -def _select( - strategies: Sequence[Strategy], strategy_ids: set[str], sdk_functions: set[str] -) -> tuple[HarnessCase, ...]: - known_ids = {strategy.id for strategy in strategies} - unknown = strategy_ids - known_ids - if unknown: - raise ValueError(f"Unknown strategy: {', '.join(sorted(unknown))}") - return tuple( - case - for strategy in strategies - if not strategy_ids or strategy.id in strategy_ids - for case in strategy.cases - if not sdk_functions or case.sdk_function in sdk_functions - ) - - -def _print_catalog(strategies: Sequence[Strategy]) -> None: - for strategy in strategies: - print(f"{strategy.id:20} {strategy.label}") - for case in strategy.cases: - selectors = ( - ", ".join(case.selectors) if case.selectors else case.unit_suite or "no test configured" - ) - print(f" {case.surface}/{case.sdk_function:12} {case.coverage.value:14} {selectors}") - - -def _print_function_report(report: FunctionReport) -> None: - print(f"\n{report.sdk_function}") - if report.ledger is None or report.audit is None: - print(" no ledger yet") - return - ledger, audit = report.ledger, report.audit - print( - f" {ledger.mapped_count}/{ledger.total_count} python tests mapped to rust " - f"({ledger.percentage}%)" - ) - print(f" {len(ledger.rust_only_tests)} rust-only tests with no python counterpart") - if audit.is_clean: - print(" ledger is in sync with the live test files") - return - for label, items in ( - ("ledger references a python test that no longer exists", audit.missing_python_tests), - ("python test exists but is not tracked in the ledger", audit.stale_python_tests), - ("ledger references a rust test that no longer exists", audit.missing_rust_tests), - ("rust test exists but is not tracked in the ledger", audit.stale_rust_tests), - ): - for item in items: - print(f" {label}: {item}") - - -def _validate_ledger(sdk_functions: set[str]) -> int: - functions = sdk_functions or set(SDK_FUNCTIONS) - reports = tuple(build_function_report(function) for function in sorted(functions)) - for report in reports: - _print_function_report(report) - return 0 if all(report.is_clean for report in reports) else 1 - - -def _resolve_runner(strategy_id: str) -> StrategyRunner: - match strategy_id: - case "e2e_parity": - return run_e2e - case "trace_parity": - return run_trace - case "unit_tests": - return run_units - case "existing_e2e_test_sdk": - return run_existing - case _: - raise ValueError(f"Unknown strategy: {strategy_id}") - - -def main(argv: Sequence[str] | None = None, *, strategy_id: str | None = None) -> int: - args = _parser().parse_args(argv) - if args.coverage and importlib.util.find_spec("pytest_cov") is None: - _parser().error( - "--coverage requires the project's pytest-cov dependency; run with " - "`poetry run python -m tests.rust-python-harness --coverage`" - ) - if args.validate_ledger: - return _validate_ledger(set(args.sdk_functions)) - catalog = load_catalog() - strategies = tuple(strategy for strategy in catalog if strategy_id is None or strategy.id == strategy_id) - if args.list: - _print_catalog(strategies) - return 0 - - strategy_ids = set(args.strategy) - sdk_functions = set(args.sdk_functions) - if args.interactive: - picked_strategies, picked_functions = _interactive_filters(strategies) - strategy_ids = strategy_ids or picked_strategies - sdk_functions = sdk_functions or picked_functions - - try: - selected = _select(strategies, strategy_ids, sdk_functions) - cases = tuple(case for case in selected if args.surface is None or case.surface == args.surface) - except ValueError as exc: - _parser().error(str(exc)) - selected_strategy_ids = {case.strategy_id for case in cases} - visible_strategies = tuple( - strategy for strategy in strategies if strategy.id in selected_strategy_ids - ) - dashboard = make_dashboard( - visible_strategies, - plain=args.plain, - confidence_strategies=strategies, - ) - pytest_args = [*args.pytest_arg] - if args.coverage: - pytest_args.extend(_coverage_pytest_args()) - with dashboard: - exit_code, run = run_strategies( - cases=cases, - repo_root=REPO_ROOT, - on_update=dashboard.update, - pytest_args=pytest_args, - resolve_runner=_resolve_runner, - ) - dashboard.finish(run, exit_code) - if args.coverage and (COVERAGE_ROOT / "python.json").exists(): - print(f"Python LOC heatmap: {COVERAGE_ROOT / 'python-html' / 'index.html'}") - print(f"Machine-readable coverage: {COVERAGE_ROOT / 'python.json'}") - return exit_code diff --git a/tests/rust-python-harness/cli/__init__.py b/tests/rust-python-harness/cli/__init__.py new file mode 100644 index 00000000000..13b995825dd --- /dev/null +++ b/tests/rust-python-harness/cli/__init__.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +import sys +from collections.abc import Sequence +from typing import Final, cast + +import click + +from ..shared.reporting.models import SDK_FUNCTIONS, SdkFunction, Strategy, Surface +from .catalog import load_catalog +from .commands import run_command, select_cases + +__all__ = ["load_catalog", "main"] + +_INTERRUPTED_EXIT_CODE: Final = 130 + + +def _function_option() -> click.Option: + return click.Option( + ("--function", "sdk_functions"), + type=click.Choice(SDK_FUNCTIONS), + multiple=True, + help="run only this SDK function; repeat to select more than one", + ) + + +def _run_all_command(strategies: Sequence[Strategy]) -> click.Command: + def run_all(sdk_functions: tuple[str, ...]) -> int: + selected_functions: Final = cast(frozenset[SdkFunction], frozenset(sdk_functions)) + cases: Final = select_cases(strategies, selected_functions) + return run_command(strategies, cases) + + return click.Command( + "all", + params=[_function_option()], + callback=run_all, + help="run every strategy", + ) + + +def _strategy_command(strategy: Strategy) -> click.Command: + params: list[click.Parameter] = [_function_option()] + if strategy.definition.surfaces: + params.append( + click.Option( + ("--surface",), + type=click.Choice(strategy.definition.surfaces), + help="run only this API surface; omit to run every surface", + ) + ) + runner_argument: Final = strategy.definition.runner_argument + if runner_argument is not None: + params.append( + click.Option( + (runner_argument.option, "runner_args"), + multiple=True, + metavar=runner_argument.metavar, + help=runner_argument.help, + ) + ) + + def run_strategy( + sdk_functions: tuple[str, ...], + surface: str | None = None, + runner_args: tuple[str, ...] = (), + ) -> int: + selected_functions: Final = cast(frozenset[SdkFunction], frozenset(sdk_functions)) + selected_surface: Final = cast(Surface | None, surface) + cases: Final = select_cases((strategy,), selected_functions, selected_surface) + return run_command((strategy,), cases, runner_args) + + return click.Command( + strategy.id, + params=params, + callback=run_strategy, + help=strategy.description, + ) + + +def _build_cli(strategies: Sequence[Strategy]) -> click.Group: + root: Final = click.Group( + "rust-python-harness", + help="Run Rust/Python parity tests with raw progress and strategy reports.", + ) + run: Final = click.Group("run", help="run one strategy or the complete harness") + run.add_command(_run_all_command(strategies)) + for strategy in strategies: + run.add_command(_strategy_command(strategy)) + root.add_command(run) + return root + + +def main(argv: Sequence[str] | None = None) -> int: + try: + strategies: Final = load_catalog() + result: Final = _build_cli(strategies).main( + args=None if argv is None else list(argv), + prog_name="rust-python-harness", + standalone_mode=False, + ) + exit_code: Final = result if isinstance(result, int) else 0 + except click.ClickException as error: + error.show() + return error.exit_code + except click.Abort: + click.echo("Aborted!", err=True) + return 1 + except KeyboardInterrupt: + sys.stderr.write("\nInterrupted\n") + return _INTERRUPTED_EXIT_CODE + if exit_code == _INTERRUPTED_EXIT_CODE: + sys.stderr.write("Interrupted\n") + return exit_code diff --git a/tests/rust-python-harness/cli/catalog.py b/tests/rust-python-harness/cli/catalog.py new file mode 100644 index 00000000000..03eb032d9c6 --- /dev/null +++ b/tests/rust-python-harness/cli/catalog.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +import hashlib +import importlib +import importlib.util +import pkgutil +import sys +from pathlib import Path +from types import ModuleType +from typing import Final + +from .. import strategies as _strategies_package +from ..shared.reporting.models import SDK_FUNCTIONS, SURFACES, CaseDisposition, HarnessCase, Strategy +from ..shared.reporting.strategy import StrategyDefinition + +_STRATEGIES_PACKAGE: Final = _strategies_package +STRATEGIES_ROOT: Final = Path(_STRATEGIES_PACKAGE.__path__[0]) + + +def _load_strategy_module(name: str, folder: Path, prefix: str | None) -> ModuleType: + if prefix is not None: + return importlib.import_module(f"{prefix}.{name}") + module_name: Final = _synthetic_module_name(folder) + spec: Final = importlib.util.spec_from_file_location( + module_name, folder / "__init__.py" + ) + if spec is None or spec.loader is None: + raise ValueError(f"{folder}: cannot load strategy package") + module: Final = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + try: + spec.loader.exec_module(module) + except Exception as error: + del sys.modules[module_name] + raise ValueError(f"{folder}: cannot import strategy package: {error}") from error + return module + + +def _synthetic_module_name(folder: Path) -> str: + digest: Final = hashlib.sha1(str(folder.resolve()).encode()).hexdigest()[:8] + return f"_harness_strategy_{folder.name}_{digest}" + + +def _load_strategy(name: str, folder: Path, prefix: str | None) -> Strategy: + module: Final = _load_strategy_module(name, folder, prefix) + definition: Final = getattr(module, "STRATEGY", None) + if not isinstance(definition, StrategyDefinition): + raise ValueError(f"{folder}: __init__.py must export STRATEGY: StrategyDefinition") + if definition.id != name: + raise ValueError(f"{folder}: strategy id {definition.id!r} must match folder name {name!r}") + if definition.directory.resolve() != folder.resolve(): + raise ValueError(f"{folder}: strategy directory must be {folder}") + if len(set(definition.surfaces)) != len(definition.surfaces) or any( + surface not in SURFACES for surface in definition.surfaces + ): + raise ValueError(f"{folder}: invalid strategy surfaces: {definition.surfaces}") + keys: Final = tuple((case.surface, case.sdk_function) for case in definition.cases) + duplicates: Final = tuple(sorted(key for key in set(keys) if keys.count(key) > 1)) + if duplicates: + raise ValueError(f"{folder}: duplicate strategy cases: {duplicates}") + expected: Final = frozenset( + (surface, function) + for surface in (definition.surfaces or (None,)) + for function in SDK_FUNCTIONS + ) + actual: Final = frozenset(keys) + if actual != expected: + missing: Final = tuple(sorted(expected - actual)) + extra: Final = tuple(sorted(actual - expected)) + raise ValueError( + f"{folder}: strategy cases must exactly match its declared matrix; missing={missing}, extra={extra}" + ) + incompatible: Final = tuple( + (case.surface, case.sdk_function) + for case in definition.cases + if case.spec.disposition is CaseDisposition.RUNNABLE + and not isinstance(case.spec, definition.runnable_spec) + ) + if incompatible: + raise ValueError(f"{folder}: runnable cases do not match {definition.runnable_spec.__name__}: {incompatible}") + cases: Final = tuple( + HarnessCase( + strategy_id=definition.id, + strategy_label=definition.label, + sdk_function=case.sdk_function, + spec=case.spec, + surface=case.surface, + ) + for case in definition.cases + ) + return Strategy( + definition.order, + definition.id, + definition.label, + definition.description, + definition.directory, + cases, + definition, + ) + + +def load_catalog(root: Path | None = None) -> tuple[Strategy, ...]: + resolved: Final = STRATEGIES_ROOT if root is None else root + prefix: Final = _STRATEGIES_PACKAGE.__name__ if resolved == STRATEGIES_ROOT else None + folders: Final = tuple( + info.name for info in pkgutil.iter_modules([str(resolved)]) if info.ispkg + ) + if not folders: + raise ValueError(f"No strategy packages found below {resolved}") + strategies: Final = tuple( + _load_strategy(name, resolved / name, prefix) for name in sorted(folders) + ) + ids: Final = [strategy.id for strategy in strategies] + if len(set(ids)) != len(ids): + raise ValueError(f"Duplicate strategy id in {resolved}") + return tuple(sorted(strategies, key=lambda strategy: (strategy.order, strategy.id))) diff --git a/tests/rust-python-harness/cli/commands.py b/tests/rust-python-harness/cli/commands.py new file mode 100644 index 00000000000..f94c3277dc2 --- /dev/null +++ b/tests/rust-python-harness/cli/commands.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from collections.abc import Sequence, Set +from dataclasses import replace +from pathlib import Path +from typing import Final + +from ..shared.reporting.models import HarnessCase, SdkFunction, Strategy, Surface +from ..shared.reporting.orchestration import run_strategies +from ..shared.reporting.ui import make_dashboard + +REPO_ROOT: Final = Path(__file__).resolve().parents[3] + + +def select_cases( + strategies: Sequence[Strategy], + sdk_functions: Set[SdkFunction], + surface: Surface | None = None, +) -> tuple[HarnessCase, ...]: + return tuple( + case + for strategy in strategies + for case in strategy.cases + if (not sdk_functions or case.sdk_function in sdk_functions) + and (surface is None or case.surface == surface) + ) + + +def run_command( + strategies: Sequence[Strategy], + cases: Sequence[HarnessCase], + runner_args: Sequence[str] = (), +) -> int: + grouped: Final = { + strategy.id: tuple(case for case in cases if case.strategy_id == strategy.id) + for strategy in strategies + } + visible: Final = tuple(strategy for strategy in strategies if grouped[strategy.id]) + runners: Final = tuple(replace(strategy, cases=grouped[strategy.id]) for strategy in visible) + dashboard: Final = make_dashboard(visible) + with dashboard: + exit_code, run = run_strategies(runners, REPO_ROOT, dashboard.update, runner_args) + if exit_code != 130: + dashboard.finish(run, exit_code) + return exit_code diff --git a/tests/rust-python-harness/cli/test_cli.py b/tests/rust-python-harness/cli/test_cli.py new file mode 100644 index 00000000000..226c89843d0 --- /dev/null +++ b/tests/rust-python-harness/cli/test_cli.py @@ -0,0 +1,458 @@ +from __future__ import annotations + +import importlib +from collections.abc import Callable, Sequence +from dataclasses import replace +from pathlib import Path +from typing import Final + +import pytest + +from ..shared.reporting.models import ( + SDK_FUNCTIONS, + SURFACES, + CaseDisposition, + HarnessCase, + HarnessRun, + RunStatus, + Strategy, +) +from ..shared.reporting.strategy import NotImplementedCaseSpec, SkippedCaseSpec, StrategyDefinition +from ..shared.reporting.ui import PlainDashboard, final_report, make_dashboard +from ..strategies.unit_tests_mapping.mappings import UNIT_TEST_CONTRACTS +from ..strategies.unit_tests_parity import UNIT_PARITY_SUITES +from ..strategies.unit_tests_rust import RUST_SUITES +from . import main +from .catalog import STRATEGIES_ROOT, load_catalog +from .commands import REPO_ROOT, select_cases + + +def _strategy_source( + *, + strategy_id: str = "example", + surfaces: tuple[str, ...] = (), + drop: tuple[str | None, str] | None = None, + duplicate: tuple[str | None, str] | None = None, + incompatible: tuple[str | None, str] | None = None, +) -> str: + cells: Final = tuple( + (surface, function) + for surface in (surfaces or (None,)) + for function in SDK_FUNCTIONS + if (surface, function) != drop + ) + definitions: Final = tuple( + ( + f"strategy.CaseDefinition({function!r}, " + "strategy.ModuleCaseSpec(coverage=models.Coverage.COMPLETE, module='tests.example'), " + f"surface={surface!r})" + if (surface, function) == incompatible + else ( + f"strategy.CaseDefinition({function!r}, " + "strategy.NotImplementedCaseSpec(reason='Not implemented yet'), " + f"surface={surface!r})" + ) + ) + for surface, function in (*cells, *((duplicate,) if duplicate is not None else ())) + ) + return ( + "import importlib\n" + "from pathlib import Path\n" + "strategy = importlib.import_module('tests.rust-python-harness.shared.reporting.strategy')\n" + "models = importlib.import_module('tests.rust-python-harness.shared.reporting.models')\n" + "runner = importlib.import_module('tests.rust-python-harness.strategies.trace_parity.runner')\n" + "rendering = importlib.import_module('tests.rust-python-harness.shared.reporting.rendering')\n" + "def render(results):\n" + " return (rendering.ReportSection('Example outcomes', " + "tuple(rendering.render_case_outcome(r) for r in results)),)\n" + f"CASES = ({','.join(definitions)},)\n" + "STRATEGY = strategy.StrategyDefinition(" + f"id={strategy_id!r}, order=1, label='Example strategy', description='Example description', " + "directory=Path(__file__).parent, runnable_spec=strategy.SuiteCaseSpec, cases=CASES, " + f"run=runner.run_trace_cases, render=render, surfaces={surfaces!r})\n" + ) + + +def _write_strategy_folder( + root: Path, + name: str = "example", + *, + init_source: str | None = None, +) -> Path: + folder: Final = root / name + folder.mkdir(parents=True) + (folder / "__init__.py").write_text(init_source or _strategy_source(), encoding="utf-8") + return folder + + +def test_should_load_surface_aware_and_function_only_strategies() -> None: + strategies: Final = load_catalog() + + assert [strategy.id for strategy in strategies] == [ + "e2e_parity", + "trace_parity", + "unit_tests_mapping", + "unit_tests_parity", + "unit_tests_rust", + ] + for strategy in strategies: + expected: Final = tuple( + (surface, function) for surface in (strategy.definition.surfaces or (None,)) for function in SDK_FUNCTIONS + ) + assert tuple((case.surface, case.sdk_function) for case in strategy.cases) == expected + + +def test_unit_strategies_use_function_only_cases() -> None: + strategies: Final = { + strategy.id: strategy + for strategy in load_catalog() + if strategy.id in {"unit_tests_mapping", "unit_tests_parity", "unit_tests_rust"} + } + + for sdk_function in SDK_FUNCTIONS: + cases: Final = tuple( + case for strategy in strategies.values() for case in strategy.cases if case.sdk_function == sdk_function + ) + assert len(cases) == 3 + assert all(case.surface is None for case in cases) + expected_mapping: Final = ( + CaseDisposition.RUNNABLE if sdk_function in UNIT_TEST_CONTRACTS else CaseDisposition.NOT_IMPLEMENTED + ) + assert cases[0].spec.disposition is expected_mapping + expected_parity: Final = ( + CaseDisposition.RUNNABLE if sdk_function in UNIT_PARITY_SUITES else CaseDisposition.NOT_IMPLEMENTED + ) + expected_rust: Final = ( + CaseDisposition.RUNNABLE if sdk_function in RUST_SUITES else CaseDisposition.NOT_IMPLEMENTED + ) + assert cases[1].spec.disposition is expected_parity + assert cases[2].spec.disposition is expected_rust + + +def test_raw_dashboard_is_always_the_default() -> None: + assert isinstance(make_dashboard(load_catalog()), PlainDashboard) + + +def test_every_strategy_folder_complies() -> None: + strategies: Final = load_catalog() + folders: Final = { + path.name for path in STRATEGIES_ROOT.iterdir() if path.is_dir() and (path / "__init__.py").exists() + } + + assert folders == {strategy.id for strategy in strategies} + for strategy in strategies: + definition: Final = strategy.definition + assert isinstance(definition, StrategyDefinition) + assert definition.directory == strategy.directory + assert not (strategy.directory / "strategy.json").exists() + assert (strategy.directory / "AGENTS.md").exists() + for case in strategy.cases: + if case.spec.disposition is CaseDisposition.RUNNABLE: + assert isinstance(case.spec, definition.runnable_spec) + + +@pytest.mark.parametrize("surfaces", ((), SURFACES)) +def test_should_reject_a_registry_missing_a_declared_matrix_cell(tmp_path: Path, surfaces: tuple[str, ...]) -> None: + surface: Final = surfaces[0] if surfaces else None + _write_strategy_folder( + tmp_path, + init_source=_strategy_source(surfaces=surfaces, drop=(surface, "count_tokens")), + ) + + with pytest.raises(ValueError, match="must exactly match its declared matrix"): + load_catalog(tmp_path) + + +def test_should_reject_a_duplicate_matrix_cell(tmp_path: Path) -> None: + _write_strategy_folder(tmp_path, init_source=_strategy_source(duplicate=(None, "ocr"))) + + with pytest.raises(ValueError, match="duplicate strategy cases"): + load_catalog(tmp_path) + + +def test_should_reject_invalid_declared_surfaces(tmp_path: Path) -> None: + _write_strategy_folder(tmp_path, init_source=_strategy_source(surfaces=("sdk", "sdk"))) + + with pytest.raises(ValueError, match="invalid strategy surfaces"): + load_catalog(tmp_path) + + +def test_should_reject_a_folder_without_a_strategy_definition(tmp_path: Path) -> None: + folder: Final = tmp_path / "example" + folder.mkdir() + (folder / "__init__.py").write_text("VALUE = 1\n", encoding="utf-8") + + with pytest.raises(ValueError, match="STRATEGY"): + load_catalog(tmp_path) + + +def test_should_reject_a_strategy_id_that_differs_from_its_folder(tmp_path: Path) -> None: + _write_strategy_folder(tmp_path, init_source=_strategy_source(strategy_id="other")) + + with pytest.raises(ValueError, match="must match folder name"): + load_catalog(tmp_path) + + +def test_should_reject_a_runnable_case_incompatible_with_the_strategy(tmp_path: Path) -> None: + _write_strategy_folder(tmp_path, init_source=_strategy_source(incompatible=(None, "ocr"))) + + with pytest.raises(ValueError, match="runnable cases do not match SuiteCaseSpec"): + load_catalog(tmp_path) + + +@pytest.mark.parametrize("case_type", (NotImplementedCaseSpec, SkippedCaseSpec)) +def test_should_reject_an_unavailable_case_with_a_blank_reason( + case_type: type[NotImplementedCaseSpec] | type[SkippedCaseSpec], +) -> None: + with pytest.raises(ValueError, match="at least 1 character"): + case_type(reason=" ") + + +def test_should_select_functions_and_surfaces() -> None: + strategy: Final = next(strategy for strategy in load_catalog() if strategy.id == "e2e_parity") + + assert tuple(case.key for case in select_cases((strategy,), {"messages"})) == ( + "e2e_parity:messages", + "e2e_parity:gateway:messages", + ) + assert tuple(case.display_name for case in select_cases((strategy,), {"ocr"}, "gateway")) == ("gateway/ocr",) + + +def _assert_unavailable_cell(strategy: Strategy, case: HarnessCase, section_title: str) -> None: + spec: Final = case.spec + assert isinstance(spec, (NotImplementedCaseSpec, SkippedCaseSpec)) + scoped: Final = replace(strategy, cases=(case,)) + exit_code, run = strategy.definition.run((case,), REPO_ROOT, lambda _: None) + result: Final = run.results[case.key] + expected: Final = ( + RunStatus.NOT_IMPLEMENTED if spec.disposition is CaseDisposition.NOT_IMPLEMENTED else RunStatus.SKIPPED + ) + report: Final = final_report(run, exit_code, (scoped,)) + + assert exit_code == 0 + assert result.status is expected + assert spec.reason in report + assert section_title in report + expected_result: Final = "NOT RUN" if expected is RunStatus.NOT_IMPLEMENTED else "SKIPPED" + expected_implemented: Final = 0 if expected is RunStatus.NOT_IMPLEMENTED else 1 + assert f"Result: {expected_result}" in report + assert f"Harness support: {expected_implemented}/1 cases implemented" in report + + +def test_every_unavailable_case_finishes_and_explains_itself() -> None: + section_titles: Final = { + "e2e_parity": "End-to-end parity outcomes", + "trace_parity": "trace comparisons", + "unit_tests_mapping": "Python/Rust unit-test mappings", + "unit_tests_parity": "Python backend parity outcomes", + "unit_tests_rust": "Native Rust unit-test outcomes", + } + unavailable: Final = tuple( + (strategy, case) + for strategy in load_catalog() + for case in strategy.cases + if case.spec.disposition is not CaseDisposition.RUNNABLE + ) + + for strategy, case in unavailable: + _assert_unavailable_cell(strategy, case, section_titles[strategy.id]) + + +@pytest.mark.parametrize( + ("strategy_id", "present", "absent"), + ( + ("e2e_parity", "--surface", "--pytest-arg"), + ("trace_parity", "--surface", "--pytest-arg"), + ("unit_tests_parity", "--pytest-arg", "--surface"), + ("unit_tests_mapping", "--detail", "--surface"), + ("unit_tests_rust", "--function", "--surface"), + ), +) +def test_strategy_help_only_lists_supported_options( + strategy_id: str, + present: str, + absent: str, + capsys: pytest.CaptureFixture[str], +) -> None: + exit_code: Final = main(["run", strategy_id, "--help"]) + captured: Final = capsys.readouterr() + + assert exit_code == 0 + assert present in captured.out + assert absent not in captured.out + + +def test_run_help_lists_all_and_every_strategy(capsys: pytest.CaptureFixture[str]) -> None: + exit_code: Final = main(["run", "--help"]) + captured: Final = capsys.readouterr() + + assert exit_code == 0 + for command in ( + "all", + "e2e_parity", + "trace_parity", + "unit_tests_mapping", + "unit_tests_parity", + "unit_tests_rust", + ): + assert command in captured.out + + +@pytest.mark.parametrize( + "argv", + ( + ("list",), + ("check",), + ("run", "--strategy", "unit_tests_parity"), + ("run", "unit_tests_parity", "--surface", "sdk"), + ("run", "unit_tests_parity", "--plain"), + ("run", "unit_tests_parity", "--runner-arg=-x"), + ("run", "all", "--pytest-arg=-x"), + ), +) +def test_removed_commands_and_options_are_rejected(argv: tuple[str, ...], capsys: pytest.CaptureFixture[str]) -> None: + exit_code: Final = main(argv) + captured: Final = capsys.readouterr() + + assert exit_code == 2 + assert captured.err + + +def test_strategy_command_forwards_repeated_filters_and_runner_arguments( + monkeypatch: pytest.MonkeyPatch, +) -> None: + cli: Final = importlib.import_module("tests.rust-python-harness.cli") + captured: list[tuple[tuple[str, ...], tuple[str, ...], tuple[str, ...]]] = [] + + def capture_run( + strategies: Sequence[Strategy], + cases: Sequence[HarnessCase], + runner_args: Sequence[str] = (), + ) -> int: + captured.append( + ( + tuple(strategy.id for strategy in strategies), + tuple(case.display_name for case in cases), + tuple(runner_args), + ) + ) + return 0 + + monkeypatch.setattr(cli, "run_command", capture_run) + + assert ( + main( + [ + "run", + "unit_tests_parity", + "--function", + "ocr", + "--function", + "messages", + "--pytest-arg=-x", + ] + ) + == 0 + ) + assert captured == [ + (("unit_tests_parity",), ("ocr", "messages"), ("-x",)), + ] + + +def test_omitted_surface_selects_every_strategy_surface(monkeypatch: pytest.MonkeyPatch) -> None: + cli: Final = importlib.import_module("tests.rust-python-harness.cli") + selected: list[str] = [] + + def capture_run( + strategies: Sequence[Strategy], + cases: Sequence[HarnessCase], + runner_args: Sequence[str] = (), + ) -> int: + del strategies, runner_args + selected.extend(case.display_name for case in cases) + return 0 + + monkeypatch.setattr(cli, "run_command", capture_run) + + assert main(["run", "e2e_parity", "--function", "ocr"]) == 0 + assert selected == ["sdk/ocr", "gateway/ocr"] + + +def test_run_all_selects_every_declared_case_once(monkeypatch: pytest.MonkeyPatch) -> None: + cli: Final = importlib.import_module("tests.rust-python-harness.cli") + selected: list[HarnessCase] = [] + + def capture_run( + strategies: Sequence[Strategy], + cases: Sequence[HarnessCase], + runner_args: Sequence[str] = (), + ) -> int: + del strategies, runner_args + selected.extend(cases) + return 0 + + monkeypatch.setattr(cli, "run_command", capture_run) + + assert main(["run", "all", "--function", "ocr"]) == 0 + assert len(selected) == 7 + assert sum(case.surface is None for case in selected) == 3 + assert sum(case.surface is not None for case in selected) == 4 + + +def test_run_reports_not_implemented_surface_as_not_run( + capsys: pytest.CaptureFixture[str], +) -> None: + exit_code: Final = main(["run", "trace_parity", "--surface", "gateway", "--function", "ocr"]) + captured: Final = capsys.readouterr() + + assert exit_code == 0 + assert "Result: NOT RUN" in captured.out + assert "Harness support: 0/1 cases implemented" in captured.out + assert "Cases: 1 selected, 1 not implemented, 0 skipped" in captured.out + assert "Not implemented" in captured.out + assert "No gateway OCR trace-parity case is registered." in captured.out + + +def test_keyboard_interrupt_exits_cleanly( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + cli: Final = importlib.import_module("tests.rust-python-harness.cli") + + def interrupt() -> tuple[object, ...]: + raise KeyboardInterrupt + + monkeypatch.setattr(cli, "load_catalog", interrupt) + + exit_code: Final = main(["run", "all"]) + captured: Final = capsys.readouterr() + + assert exit_code == 130 + assert captured.out == "" + assert captured.err == "\nInterrupted\n" + + +def test_runner_interrupt_skips_the_completion_report( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + commands: Final = importlib.import_module("tests.rust-python-harness.cli.commands") + + def interrupt_run( + strategies: Sequence[Strategy], + repo_root: Path, + on_update: Callable[[HarnessRun], None], + runner_args: Sequence[str] = (), + ) -> tuple[int, HarnessRun]: + del repo_root, on_update, runner_args + run: Final = HarnessRun.from_cases(case for strategy in strategies for case in strategy.cases) + return 130, run + + monkeypatch.setattr(commands, "run_strategies", interrupt_run) + + exit_code: Final = main(["run", "trace_parity", "--surface", "gateway"]) + captured: Final = capsys.readouterr() + + assert exit_code == 130 + assert "Rust <-> Python parity report" not in captured.out + assert captured.err == "Interrupted\n" diff --git a/tests/rust-python-harness/conftest.py b/tests/rust-python-harness/conftest.py new file mode 100644 index 00000000000..d50d0fa4204 --- /dev/null +++ b/tests/rust-python-harness/conftest.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path +from typing import Final + +import pytest + +HARNESS_ROOT: Final = Path(__file__).resolve().parents[2] + + +@pytest.fixture(autouse=True) +def subprocess_test_environment(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("PYTHONPATH", str(HARNESS_ROOT)) + monkeypatch.setenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD", "1") + + +@pytest.fixture +def cargo_project(tmp_path: Path) -> Callable[[str, str], Path]: + def create(package: str, source: str) -> Path: + manifest: Final = tmp_path / "Cargo.toml" + manifest.write_text( + f'[package]\nname = "{package}"\nversion = "0.1.0"\nedition = "2021"\n[workspace]\n' + ) + (tmp_path / "src").mkdir() + (tmp_path / "src/lib.rs").write_text(source) + return manifest + + return create diff --git a/tests/rust-python-harness/shared/native_build.py b/tests/rust-python-harness/shared/native_build.py new file mode 100644 index 00000000000..2ca7131c2c1 --- /dev/null +++ b/tests/rust-python-harness/shared/native_build.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import importlib.util +import os +import subprocess +import sys +from collections.abc import Iterator +from pathlib import Path +from typing import Final + +from litellm.rust_bridge import get_native_bridge, reset_native_bridge_cache + +MATURIN_SPEC: Final = "maturin==1.15.0" +BRIDGE_FEATURE: Final = "trace-parity" +_RUST_ROOT: Final = "litellm-rust" +_LOCKFILE: Final = "Cargo.lock" +_SOURCE_SUFFIXES: Final = frozenset({".rs", ".toml"}) +_FAILURE_OUTPUT_LINES: Final = 15 + + +def needs_rebuild(native_mtime: float | None, newest_source_mtime: float | None) -> bool: + if native_mtime is None: + return True + if newest_source_mtime is None: + return False + return newest_source_mtime > native_mtime + + +def _source_files(rust_root: Path) -> Iterator[Path]: + for path in rust_root.rglob("*"): + relative: Final = path.relative_to(rust_root) + if "target" in relative.parts or not path.is_file(): + continue + if path.name == _LOCKFILE or path.suffix in _SOURCE_SUFFIXES: + yield path + + +def _newest_source_mtime(repo_root: Path) -> float | None: + rust_root: Final = repo_root / _RUST_ROOT + if not rust_root.is_dir(): + return None + return max((path.stat().st_mtime for path in _source_files(rust_root)), default=None) + + +def _native_module_path() -> Path | None: + try: + spec: Final = importlib.util.find_spec("litellm.rust_bridge._native") + except (ImportError, ValueError): + return None + origin: Final = getattr(spec, "origin", None) + return Path(origin) if origin else None + + +def _drop_imported_bridge() -> None: + reset_native_bridge_cache() + for name in tuple(sys.modules): + if name.startswith("litellm.rust_bridge._native"): + del sys.modules[name] + + +def _rebuild(repo_root: Path) -> tuple[bool, str]: + command: Final = ("uvx", "--from", MATURIN_SPEC, "maturin", "develop", "--features", BRIDGE_FEATURE) + completed: Final = subprocess.run( + command, + cwd=repo_root, + env={**os.environ, "VIRTUAL_ENV": sys.prefix}, + capture_output=True, + text=True, + check=False, + ) + output: Final = f"{completed.stdout}\n{completed.stderr}".strip() + lines: Final = tuple(output.splitlines()) + return completed.returncode == 0, "\n".join(lines[-_FAILURE_OUTPUT_LINES:]) + + +def ensure_trace_bridge(repo_root: Path) -> str | None: + native_path: Final = _native_module_path() + native_mtime: Final = native_path.stat().st_mtime if native_path is not None and native_path.exists() else None + if needs_rebuild(native_mtime, _newest_source_mtime(repo_root)): + print(f"Rebuilding native Rust bridge ({BRIDGE_FEATURE} feature)...", flush=True) + succeeded: Final + output: Final + succeeded, output = _rebuild(repo_root) + if not succeeded: + return f"native Rust bridge rebuild failed:\n{output}" + _drop_imported_bridge() + bridge: Final = get_native_bridge() + if bridge is None: + return "native Rust bridge is not importable" + if getattr(bridge, "_trace", None) is None: + return f"native Rust bridge does not expose _trace; it must be built with the {BRIDGE_FEATURE} feature" + return None diff --git a/tests/rust-python-harness/shared/parity/__init__.py b/tests/rust-python-harness/shared/parity/__init__.py index f18197acd2a..e69de29bb2d 100644 --- a/tests/rust-python-harness/shared/parity/__init__.py +++ b/tests/rust-python-harness/shared/parity/__init__.py @@ -1,3 +0,0 @@ -import pytest - -pytest.register_assert_rewrite("tests.rust-python-harness.shared.parity.compare") diff --git a/tests/rust-python-harness/shared/parity/fixtures/__init__.py b/tests/rust-python-harness/shared/parity/fixtures/__init__.py index 9d48db4f9f8..6d7f8b4f048 100644 --- a/tests/rust-python-harness/shared/parity/fixtures/__init__.py +++ b/tests/rust-python-harness/shared/parity/fixtures/__init__.py @@ -1 +1,7 @@ from __future__ import annotations + +from typing import Final + +from pydantic import TypeAdapter + +JSON_OBJECT_ADAPTER: Final = TypeAdapter(dict[str, object]) diff --git a/tests/rust-python-harness/shared/parity/fixtures/cassette.py b/tests/rust-python-harness/shared/parity/fixtures/cassette.py index 03a5fc9f416..79e04d63b22 100644 --- a/tests/rust-python-harness/shared/parity/fixtures/cassette.py +++ b/tests/rust-python-harness/shared/parity/fixtures/cassette.py @@ -5,11 +5,10 @@ from datetime import datetime from itertools import accumulate from typing import Final, Literal -from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, TypeAdapter +from pydantic import AwareDatetime, BaseModel, ConfigDict, Field from vcr.serialize import serialize from vcr.serializers import yamlserializer -from .recording import RecordedInteraction from ..recorded_http import ( HttpHeader, RecordedHttpResponse, @@ -17,8 +16,8 @@ from ..recorded_http import ( RecordedResponse, RecordedStreamChunk, ) - -_OBJECT: Final = TypeAdapter(dict[str, object]) +from . import JSON_OBJECT_ADAPTER +from .recording import RecordedInteraction class _CassetteModel(BaseModel): @@ -118,7 +117,7 @@ def serialize_cassette( recorded_at: datetime, request_source: Literal["recorded", "python_replay"], ) -> str: - normalized: Final = _OBJECT.validate_python( + normalized: Final = JSON_OBJECT_ADAPTER.validate_python( yamlserializer.deserialize( serialize( { diff --git a/tests/rust-python-harness/shared/parity/fixtures/pipeline.py b/tests/rust-python-harness/shared/parity/fixtures/pipeline.py index ab5c3437c2a..8a6f72b4a4d 100644 --- a/tests/rust-python-harness/shared/parity/fixtures/pipeline.py +++ b/tests/rust-python-harness/shared/parity/fixtures/pipeline.py @@ -8,11 +8,11 @@ from types import MappingProxyType from typing import Final, Generic, Literal, Protocol, TypeVar from hypothesis.strategies import SearchStrategy -from pydantic import BaseModel from .inputs import generate_case_inputs from .recording import UpstreamEndpoint, record_upstream_interactions from .store import ( + CaseT, FixtureInput, canonical_json, fixture_cache_key, @@ -25,7 +25,6 @@ from .store import ( 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]): diff --git a/tests/rust-python-harness/shared/parity/fixtures/pytest_support.py b/tests/rust-python-harness/shared/parity/fixtures/pytest_support.py index 04c097f318c..844417d1c6b 100644 --- a/tests/rust-python-harness/shared/parity/fixtures/pytest_support.py +++ b/tests/rust-python-harness/shared/parity/fixtures/pytest_support.py @@ -3,14 +3,12 @@ from __future__ import annotations import os from collections.abc import Callable from pathlib import Path -from typing import Final, TypeVar +from typing import Final import pytest -from pydantic import BaseModel, ValidationError +from pydantic import ValidationError -from .store import recorded_fixtures - -CaseT = TypeVar("CaseT", bound=BaseModel) +from .store import CaseT, fixture_directory, recorded_fixtures def parametrize_recorded_fixtures( @@ -27,9 +25,10 @@ def parametrize_recorded_fixtures( 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: + directory: Final = fixture_directory(None, configured, default_directory) + except ValueError as error: + raise pytest.UsageError(f"{env_var} is set but empty") from error try: fixtures: Final = recorded_fixtures(directory, case_type) except (ValidationError, ValueError) as error: diff --git a/tests/rust-python-harness/shared/parity/fixtures/recording.py b/tests/rust-python-harness/shared/parity/fixtures/recording.py index 6c23e36f20c..ee0d4b6de7b 100644 --- a/tests/rust-python-harness/shared/parity/fixtures/recording.py +++ b/tests/rust-python-harness/shared/parity/fixtures/recording.py @@ -1,23 +1,24 @@ from __future__ import annotations import queue -import threading from collections.abc import Callable, Generator, Iterable -from contextlib import contextmanager +from contextlib import AbstractContextManager 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 ..http import ( + PARITY_PROVIDER_HOST, dropped_request_headers, dropped_response_headers, is_streaming_response, + local_response_header, + normalized_response_header, ) +from ..local_server import LocalHttpHandler, LocalHttpServer, serve_in_thread from ..recorded_http import ( HttpHeader, RecordedHttpResponse, @@ -26,7 +27,6 @@ from ..recorded_http import ( RecordedStreamChunk, ) -_PARITY_PROVIDER_HOST: Final = "parity-provider.invalid" _SECRET_HEADERS: Final = frozenset( { "authorization", @@ -61,42 +61,18 @@ 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)) + 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 - +class _RecordingProvider(LocalHttpServer): 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) @@ -106,9 +82,7 @@ class _RecordingProvider(ThreadingHTTPServer): return (first, *remaining) -class _RecordingHandler(BaseHTTPRequestHandler): - protocol_version = "HTTP/1.1" - +class _RecordingHandler(LocalHttpHandler): def do_POST(self) -> None: self._forward() @@ -151,7 +125,7 @@ class _RecordingHandler(BaseHTTPRequestHandler): recorded_request: Final = remove_query_parameters( Request( self.command, - f"http://{_PARITY_PROVIDER_HOST}{self.path}", + f"http://{PARITY_PROVIDER_HOST}{self.path}", request_body, {name: value for name, value in forwarded_headers if name.lower() not in _SECRET_HEADERS}, ), @@ -191,8 +165,10 @@ class _RecordingHandler(BaseHTTPRequestHandler): 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() + try: + self.finish_chunked() + except (BrokenPipeError, ConnectionResetError): + pass return RecordedHttpStreamResponse( kind="http_stream", status_code=upstream.status_code, @@ -202,10 +178,7 @@ class _RecordingHandler(BaseHTTPRequestHandler): 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() + self.write_chunk(chunk) yield RecordedStreamChunk.from_bytes(chunk) def _send_response(self, status_code: int, headers: tuple[HttpHeader, ...], body: bytes) -> None: @@ -218,21 +191,8 @@ class _RecordingHandler(BaseHTTPRequestHandler): 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 _recording_provider(spec: UpstreamEndpoint) -> AbstractContextManager[_RecordingProvider]: + return serve_in_thread(_RecordingProvider(spec)) def _invoke_and_take_interactions( diff --git a/tests/rust-python-harness/shared/parity/fixtures/store.py b/tests/rust-python-harness/shared/parity/fixtures/store.py index 7a10c5c6c5d..270af2a7625 100644 --- a/tests/rust-python-harness/shared/parity/fixtures/store.py +++ b/tests/rust-python-harness/shared/parity/fixtures/store.py @@ -8,15 +8,13 @@ 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 pydantic import AwareDatetime, BaseModel, ConfigDict, ValidationError +from . import JSON_OBJECT_ADAPTER from .cassette import deserialize_cassette, serialize_cassette from .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]: ... @@ -40,10 +38,13 @@ def fixture_cache_key(case_input: FixtureInput) -> dict[str, object]: return case_input.canonical_input() -def fixture_path(directory: Path, case_input: FixtureInput) -> Path: +def _fixture_digest(case_input: FixtureInput) -> str: 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" + return hashlib.sha256(input_json.encode("utf-8")).hexdigest() + + +def fixture_path(directory: Path, case_input: FixtureInput) -> Path: + return directory / f"{_fixture_digest(case_input)}.yaml" def load_fixture(directory: Path, case_input: FixtureInput, case_type: type[CaseT]) -> CaseT | None: @@ -87,7 +88,7 @@ def save_fixture( 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) + return _load_fixture(JSON_OBJECT_ADAPTER.validate_json(contents), path, case_type) try: cassette: Final = deserialize_cassette(contents) return case_type.model_validate(cassette.case_data()) @@ -117,10 +118,12 @@ def recorded_fixtures(directory: Path, case_type: type[CaseT]) -> tuple[CaseT, . def fixture_directory(configured: Path | None, env_value: str | None, default: Path) -> Path: - return (configured or Path(env_value or default)).expanduser() + if configured is not None: + return configured.expanduser() + if env_value == "": + raise ValueError("fixture directory environment variable is set but empty") + return Path(env_value).expanduser() if env_value is not None else 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}" + return f"{prefix}-{_fixture_digest(case_input)[:8]}" diff --git a/tests/rust-python-harness/shared/parity/fixtures/test_cassette.py b/tests/rust-python-harness/shared/parity/fixtures/test_cassette.py index e668223782b..7850f0863f8 100644 --- a/tests/rust-python-harness/shared/parity/fixtures/test_cassette.py +++ b/tests/rust-python-harness/shared/parity/fixtures/test_cassette.py @@ -10,9 +10,6 @@ from vcr import VCR from vcr.request import Request from ..fixture_models import ParityCase, SdkInputBase -from .cassette import deserialize_cassette -from .recording import RecordedInteraction -from .store import load_fixture, save_fixture from ..recorded_http import ( HttpHeader, RecordedHttpResponse, @@ -21,6 +18,9 @@ from ..recorded_http import ( RecordedStreamChunk, ) from ..replay import replay_server +from .cassette import deserialize_cassette +from .recording import RecordedInteraction +from .store import load_fixture, save_fixture _URI: Final = "http://parity-provider.invalid/operation?api-version=1" @@ -93,3 +93,18 @@ def test_cassette_preserves_duplicate_response_headers(tmp_path: Path) -> None: save_fixture(tmp_path, sdk_input, case, (RecordedInteraction(Request("POST", _URI, b"", {}), response),)) assert load_fixture(tmp_path, sdk_input, ParityCase[_Input]) == case + + +def test_local_replay_skips_recorded_retry_delay() -> None: + response: Final = RecordedHttpResponse.from_bytes( + 200, + (HttpHeader(name="retry-after", value="5"),), + b"{}", + ) + + with replay_server() as server: + server.enqueue_response(response) + replayed: Final = httpx.post(f"{server.url}/operation", content=b"{}") + server.take_requests(1) + + assert replayed.headers["retry-after"] == "0" diff --git a/tests/rust-python-harness/shared/parity/fixtures/test_pipeline.py b/tests/rust-python-harness/shared/parity/fixtures/test_pipeline.py index 66b6b4bffdc..4535ba05bf6 100644 --- a/tests/rust-python-harness/shared/parity/fixtures/test_pipeline.py +++ b/tests/rust-python-harness/shared/parity/fixtures/test_pipeline.py @@ -2,10 +2,8 @@ from __future__ import annotations import logging import threading -from collections.abc import Generator -from contextlib import contextmanager +from contextlib import AbstractContextManager from dataclasses import dataclass -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from typing import Final, Literal @@ -14,6 +12,8 @@ import pytest from hypothesis import strategies as st from pydantic import BaseModel, ConfigDict +from ..local_server import LocalHttpHandler, LocalHttpServer, serve_in_thread +from ..recorded_http import RecordedResponse from .pipeline import ( RecordingInvocation, RecordingTarget, @@ -22,7 +22,6 @@ from .pipeline import ( ) from .recording import UpstreamEndpoint from .store import fixture_path -from ..recorded_http import RecordedResponse class _FixtureInput(BaseModel): @@ -41,20 +40,12 @@ class _ParityCase(BaseModel): provider_responses: tuple[RecordedResponse, ...] -class _Upstream(ThreadingHTTPServer): - daemon_threads = True - +class _Upstream(LocalHttpServer): def __init__(self, status: int = 200) -> None: super().__init__(("127.0.0.1", 0), _UpstreamHandler) self.response_status: Final = status - @property - def url(self) -> str: - return f"http://127.0.0.1:{self.server_address[1]}" - - -class _UpstreamHandler(BaseHTTPRequestHandler): - protocol_version = "HTTP/1.1" +class _UpstreamHandler(LocalHttpHandler): def do_POST(self) -> None: length: Final = int(self.headers.get("content-length") or "0") @@ -68,21 +59,8 @@ class _UpstreamHandler(BaseHTTPRequestHandler): self.end_headers() self.wfile.write(body) - def log_message(self, format: str, *args: object) -> None: - return - - -@contextmanager -def _upstream(status: int = 200) -> Generator[_Upstream]: - server: Final = _Upstream(status) - 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 _upstream(status: int = 200) -> AbstractContextManager[_Upstream]: + return serve_in_thread(_Upstream(status)) @dataclass(frozen=True, slots=True) diff --git a/tests/rust-python-harness/shared/parity/fixtures/test_recording.py b/tests/rust-python-harness/shared/parity/fixtures/test_recording.py index e3da59ac4d8..6181f18e89a 100644 --- a/tests/rust-python-harness/shared/parity/fixtures/test_recording.py +++ b/tests/rust-python-harness/shared/parity/fixtures/test_recording.py @@ -3,10 +3,9 @@ from __future__ import annotations import asyncio import queue import threading -from collections.abc import AsyncIterator, Callable, Generator, Iterator -from contextlib import contextmanager +from collections.abc import AsyncIterator, Callable, Iterator +from contextlib import AbstractContextManager from dataclasses import dataclass -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from typing import Final, Literal @@ -17,19 +16,8 @@ from openai._streaming import SSEDecoder from pydantic import BaseModel, ConfigDict from ..compare import assert_request_parity -from .pipeline import RecordingTarget, record_fixtures -from .recording import ( - UpstreamEndpoint, - record_upstream_interactions, - record_upstream_responses, -) -from .store import ( - FIXTURE_SCHEMA_VERSION, - fixture_path, - load_fixture, - recorded_fixtures, -) from ..inprocess import InProcessExecution, run_in_process, run_in_process_async +from ..local_server import LocalHttpHandler, LocalHttpServer, serve_in_thread from ..recorded_http import ( HttpHeader, RecordedHttpStreamResponse, @@ -45,6 +33,18 @@ from ..stream import ( consume_async_stream, consume_sync_stream, ) +from .pipeline import RecordingTarget, record_fixtures +from .recording import ( + UpstreamEndpoint, + record_upstream_interactions, + record_upstream_responses, +) +from .store import ( + FIXTURE_SCHEMA_VERSION, + fixture_path, + load_fixture, + recorded_fixtures, +) _SSE_CHUNKS: Final = ( b'data: {"choices":[{"delta":{"content":"hello"}}]}\n\n', @@ -146,9 +146,7 @@ class _Invocation: self.sdk_call(provider_url, case_input) -class _ControlledUpstream(ThreadingHTTPServer): - daemon_threads = True - +class _ControlledUpstream(LocalHttpServer): def __init__(self, stream_chunks: tuple[bytes, ...]) -> None: super().__init__(("127.0.0.1", 0), _ControlledUpstreamHandler) self.stream_chunks: Final = stream_chunks @@ -158,10 +156,6 @@ class _ControlledUpstream(ThreadingHTTPServer): 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 @@ -176,9 +170,7 @@ class _ControlledUpstream(ThreadingHTTPServer): self.active_requests -= 1 -class _ControlledUpstreamHandler(BaseHTTPRequestHandler): - protocol_version = "HTTP/1.1" - +class _ControlledUpstreamHandler(LocalHttpHandler): def do_POST(self) -> None: upstream: Final = self.server assert isinstance(upstream, _ControlledUpstream) @@ -207,13 +199,7 @@ class _ControlledUpstreamHandler(BaseHTTPRequestHandler): 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() + self.write_chunked(upstream.stream_chunks) return if self.path == "/error": self._send_json(429, b'{"error":{"message":"rate limited"}}') @@ -252,21 +238,10 @@ class _ControlledUpstreamHandler(BaseHTTPRequestHandler): 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 _controlled_upstream( + stream_chunks: tuple[bytes, ...] = _SSE_CHUNKS, +) -> AbstractContextManager[_ControlledUpstream]: + return serve_in_thread(_ControlledUpstream(stream_chunks)) def _case(identifier: str) -> _FixtureInput: diff --git a/tests/rust-python-harness/shared/parity/http.py b/tests/rust-python-harness/shared/parity/http.py index c164e3c6549..46cdbeedb59 100644 --- a/tests/rust-python-harness/shared/parity/http.py +++ b/tests/rust-python-harness/shared/parity/http.py @@ -2,6 +2,9 @@ from __future__ import annotations from collections.abc import Iterable from typing import Final +from urllib.parse import urlsplit, urlunsplit + +PARITY_PROVIDER_HOST: Final = "parity-provider.invalid" HOP_BY_HOP_HEADERS: Final[frozenset[str]] = frozenset( { @@ -52,3 +55,21 @@ def dropped_response_headers(headers: Iterable[tuple[str, str]]) -> frozenset[st def is_streaming_response(content_type: str) -> bool: return "text/event-stream" in content_type.lower() + + +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 ''}" diff --git a/tests/rust-python-harness/shared/parity/ledger.py b/tests/rust-python-harness/shared/parity/ledger.py deleted file mode 100644 index 40dfed583ae..00000000000 --- a/tests/rust-python-harness/shared/parity/ledger.py +++ /dev/null @@ -1,136 +0,0 @@ -from __future__ import annotations - -import json -from dataclasses import dataclass -from pathlib import Path -from typing import Any - -@dataclass(frozen=True, slots=True) -class LedgerEntry: - python_file: str - python_test: str - status: str - rust_file: str - rust_test: str - justification: str - reason: str - - -@dataclass(frozen=True, slots=True) -class RustOnlyEntry: - rust_file: str - rust_test: str - reason: str - - -@dataclass(frozen=True, slots=True) -class TestLedger: - sdk_function: str - python_scope: tuple[str, ...] - rust_scope: tuple[str, ...] - entries: tuple[LedgerEntry, ...] - rust_only_tests: tuple[RustOnlyEntry, ...] - - @property - def mapped_count(self) -> int: - return sum(1 for entry in self.entries if entry.status == "mapped") - - @property - def total_count(self) -> int: - return len(self.entries) - - @property - def percentage(self) -> float: - if self.total_count == 0: - return 0.0 - return round(100.0 * self.mapped_count / self.total_count, 1) - - -def _require_string(value: Any, field: str, source: Path) -> str: - if not isinstance(value, str) or not value.strip(): - raise ValueError(f"{source}: {field} must be a non-empty string") - return value - - -def _require_string_list(value: Any, field: str, source: Path) -> tuple[str, ...]: - if not isinstance(value, list) or not all(isinstance(item, str) and item for item in value): - raise ValueError(f"{source}: {field} must be a list of non-empty strings") - return tuple(value) - - -def _load_entry(data: Any, index: int, source: Path) -> LedgerEntry: - if not isinstance(data, dict): - raise ValueError(f"{source}: entries[{index}] must be an object") - python_file = _require_string(data.get("python_file"), f"entries[{index}].python_file", source) - python_test = _require_string(data.get("python_test"), f"entries[{index}].python_test", source) - status = data.get("status") - if status not in ("mapped", "unmapped"): - raise ValueError(f"{source}: entries[{index}].status must be 'mapped' or 'unmapped'") - - if status == "mapped": - rust_file = _require_string(data.get("rust_file"), f"entries[{index}].rust_file", source) - rust_test = _require_string(data.get("rust_test"), f"entries[{index}].rust_test", source) - justification = _require_string( - data.get("justification"), f"entries[{index}].justification", source - ) - return LedgerEntry( - python_file=python_file, - python_test=python_test, - status=status, - rust_file=rust_file, - rust_test=rust_test, - justification=justification, - reason="", - ) - - reason = _require_string(data.get("reason"), f"entries[{index}].reason", source) - return LedgerEntry( - python_file=python_file, - python_test=python_test, - status=status, - rust_file="", - rust_test="", - justification="", - reason=reason, - ) - - -def _load_rust_only_entry(data: Any, index: int, source: Path) -> RustOnlyEntry: - if not isinstance(data, dict): - raise ValueError(f"{source}: rust_only_tests[{index}] must be an object") - return RustOnlyEntry( - rust_file=_require_string(data.get("rust_file"), f"rust_only_tests[{index}].rust_file", source), - rust_test=_require_string(data.get("rust_test"), f"rust_only_tests[{index}].rust_test", source), - reason=_require_string(data.get("reason"), f"rust_only_tests[{index}].reason", source), - ) - - -def load_ledger(path: Path) -> TestLedger: - with path.open(encoding="utf-8") as stream: - data = json.load(stream) - - sdk_function = _require_string(data.get("sdk_function"), "sdk_function", path) - python_scope = _require_string_list(data.get("python_scope"), "python_scope", path) - rust_scope = _require_string_list(data.get("rust_scope"), "rust_scope", path) - - entries_data = data.get("entries") - if not isinstance(entries_data, list): - raise ValueError(f"{path}: entries must be a list") - entries = tuple( - _load_entry(entry, index, path) for index, entry in enumerate(entries_data) - ) - - rust_only_data = data.get("rust_only_tests") - if not isinstance(rust_only_data, list): - raise ValueError(f"{path}: rust_only_tests must be a list") - rust_only_tests = tuple( - _load_rust_only_entry(entry, index, path) for index, entry in enumerate(rust_only_data) - ) - - return TestLedger( - sdk_function=sdk_function, - python_scope=python_scope, - rust_scope=rust_scope, - entries=entries, - rust_only_tests=rust_only_tests, - ) diff --git a/tests/rust-python-harness/shared/parity/local_server.py b/tests/rust-python-harness/shared/parity/local_server.py new file mode 100644 index 00000000000..6cf2377d123 --- /dev/null +++ b/tests/rust-python-harness/shared/parity/local_server.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import threading +from collections.abc import Generator, Iterable +from contextlib import contextmanager +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import Final, TypeVar + + +class LocalHttpServer(ThreadingHTTPServer): + daemon_threads = True + + @property + def url(self) -> str: + return f"http://127.0.0.1:{self.server_address[1]}" + + +class LocalHttpHandler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def write_chunk(self, chunk: bytes) -> None: + self.wfile.write(f"{len(chunk):X}\r\n".encode("ascii")) + self.wfile.write(chunk) + self.wfile.write(b"\r\n") + self.wfile.flush() + + def finish_chunked(self) -> None: + self.wfile.write(b"0\r\n\r\n") + self.wfile.flush() + + def write_chunked(self, chunks: Iterable[bytes]) -> None: + for chunk in chunks: + self.write_chunk(chunk) + self.finish_chunked() + + def log_message(self, format: str, *args: object) -> None: + return + + +ServerT = TypeVar("ServerT", bound=LocalHttpServer) + + +@contextmanager +def serve_in_thread(server: ServerT, poll_interval: float = 0.5) -> Generator[ServerT]: + thread: Final = threading.Thread( + target=server.serve_forever, + kwargs={"poll_interval": poll_interval}, + daemon=True, + ) + thread.start() + try: + yield server + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) diff --git a/tests/rust-python-harness/shared/parity/replay.py b/tests/rust-python-harness/shared/parity/replay.py index bde84aba3c4..c7bf76895ad 100644 --- a/tests/rust-python-harness/shared/parity/replay.py +++ b/tests/rust-python-harness/shared/parity/replay.py @@ -2,15 +2,13 @@ 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 contextlib import AbstractContextManager from typing import Final from pydantic import JsonValue, TypeAdapter -from .fixtures.recording import local_response_header +from .http import local_response_header +from .local_server import LocalHttpHandler, LocalHttpServer, serve_in_thread from .models import CapturedRequest from .recorded_http import RecordedHttpResponse, RecordedHttpStreamResponse, RecordedResponse @@ -28,18 +26,18 @@ EXCLUDED_REQUEST_HEADERS: Final = frozenset( EXCLUDED_RESPONSE_HEADERS: Final = frozenset({"content-length", "transfer-encoding", "connection"}) -class ReplayServer(ThreadingHTTPServer): - daemon_threads = True +def _replay_response_header(name: str, value: str, provider_url: str) -> str: + if name.lower() == "retry-after": + return "0" + return local_response_header(name, value, provider_url) + +class ReplayServer(LocalHttpServer): 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) @@ -56,9 +54,7 @@ class ReplayServer(ThreadingHTTPServer): self.requests.get_nowait() -class _ReplayHandler(BaseHTTPRequestHandler): - protocol_version = "HTTP/1.1" - +class _ReplayHandler(LocalHttpHandler): def do_POST(self) -> None: self._replay() @@ -111,7 +107,7 @@ class _ReplayHandler(BaseHTTPRequestHandler): 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)) + self.send_header(header.name, _replay_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))) @@ -121,27 +117,8 @@ class _ReplayHandler(BaseHTTPRequestHandler): 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 + self.write_chunked(chunk.data_bytes() for chunk in response.chunks) -@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) +def replay_server() -> AbstractContextManager[ReplayServer]: + return serve_in_thread(ReplayServer(), poll_interval=0.01) diff --git a/tests/rust-python-harness/shared/parity/runner.py b/tests/rust-python-harness/shared/parity/runner.py index 5add5177113..43a583382cb 100644 --- a/tests/rust-python-harness/shared/parity/runner.py +++ b/tests/rust-python-harness/shared/parity/runner.py @@ -26,6 +26,7 @@ from .replay import ReplayServer, replay_server WORKER_RESULT_PREFIX: Final = "LITELLM_PARITY_RESULT " WORKER_RESULT_ADAPTER: Final[TypeAdapter[WorkerResult]] = TypeAdapter(WorkerResult) +PROJECT_ROOT: Final = Path(__file__).resolve().parents[4] @dataclass(frozen=True, slots=True) @@ -39,7 +40,7 @@ class SubprocessRunner: sys.executable, "-m", ".".join( - self.entrypoint.resolve().relative_to(Path(__file__).resolve().parents[4]).with_suffix("").parts + self.entrypoint.resolve().relative_to(PROJECT_ROOT).with_suffix("").parts ), "--parity-worker", provider_url, @@ -54,7 +55,7 @@ class ExecutionVariant: class SubprocessWorker: def __init__(self, runner: SubprocessRunner, provider: ReplayServer, variant: ExecutionVariant) -> None: - project_root: Final = str(Path(__file__).resolve().parents[4]) + project_root: Final = str(PROJECT_ROOT) existing_pythonpath: Final = os.environ.get("PYTHONPATH") env: Final = { **os.environ, @@ -165,15 +166,6 @@ def execution_worker( 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, diff --git a/tests/rust-python-harness/shared/parity/stream.py b/tests/rust-python-harness/shared/parity/stream.py index 72e00d3b2bd..9da7bd42d93 100644 --- a/tests/rust-python-harness/shared/parity/stream.py +++ b/tests/rust-python-harness/shared/parity/stream.py @@ -81,80 +81,61 @@ def _failed(phase: Literal["creation", "iteration"], error: Exception) -> Stream ) +def _creation_failure(error: Exception) -> StreamOutcome: + return StreamOutcome( + wrapper_type=None, + supports_sync_iteration=None, + supports_async_iteration=None, + chunks=(), + chunk_types=(), + terminal=_failed("creation", error), + ) + + +def _stream_outcome( + stream: object, + chunks: Iterable[object], + terminal: StreamTerminal, +) -> StreamOutcome: + 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=terminal, + ) + + 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), - ) + return _creation_failure(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(), - ) + return _stream_outcome(stream, chunks, _failed("iteration", error)) + return _stream_outcome(stream, chunks, 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), - ) + return _creation_failure(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(), - ) + return _stream_outcome(stream, chunks, _failed("iteration", error)) + return _stream_outcome(stream, chunks, StreamCompleted()) def normalize_chunk(chunk: object) -> object: diff --git a/tests/rust-python-harness/shared/reporting/models.py b/tests/rust-python-harness/shared/reporting/models.py index 4ffacdce9ab..f78141fa552 100644 --- a/tests/rust-python-harness/shared/reporting/models.py +++ b/tests/rust-python-harness/shared/reporting/models.py @@ -1,17 +1,25 @@ from __future__ import annotations +from collections.abc import Iterable from dataclasses import dataclass, field from enum import Enum from pathlib import Path from time import monotonic -from typing import Iterable +from typing import TYPE_CHECKING, Final, Literal, TypeAlias, assert_never + +if TYPE_CHECKING: + from .strategy import CaseSpec, StrategyDefinition class Coverage(str, Enum): COMPLETE = "complete" PARTIAL = "partial" - PLANNED = "planned" - NOT_APPLICABLE = "not_applicable" + + +class CaseDisposition(str, Enum): + RUNNABLE = "runnable" + NOT_IMPLEMENTED = "not_implemented" + SKIPPED = "skipped" class RunStatus(str, Enum): @@ -23,33 +31,45 @@ class RunStatus(str, Enum): SKIPPED = "skipped" ERROR = "error" MISSING = "missing" - PLANNED = "planned" - NOT_APPLICABLE = "not_applicable" + NOT_IMPLEMENTED = "not_implemented" -class ConfidenceLevel(str, Enum): - HIGH = "HIGH" - MEDIUM = "MEDIUM" - LOW = "LOW" - - -SDK_FUNCTIONS = ("ocr", "messages", "responses", "count_tokens", "chat_completions", "transcription") +SdkFunction: TypeAlias = Literal["ocr", "messages", "responses", "count_tokens", "chat_completions", "transcription"] +Surface: TypeAlias = Literal["sdk", "gateway"] +SURFACES: Final[tuple[Surface, ...]] = ("sdk", "gateway") +SDK_FUNCTIONS: Final[tuple[SdkFunction, ...]] = ( + "ocr", + "messages", + "responses", + "count_tokens", + "chat_completions", + "transcription", +) @dataclass(frozen=True) class HarnessCase: strategy_id: str strategy_label: str - sdk_function: str - coverage: Coverage - selectors: tuple[str, ...] - note: str = "" - surface: str = "sdk" - unit_suite: str | None = None + sdk_function: SdkFunction + spec: CaseSpec + surface: Surface | None = None @property def key(self) -> str: - return f"{self.strategy_id}:{self.sdk_function}" if self.surface == "sdk" else f"{self.strategy_id}:gateway:{self.sdk_function}" + return ( + f"{self.strategy_id}:{self.sdk_function}" + if self.surface in {None, "sdk"} + else f"{self.strategy_id}:gateway:{self.sdk_function}" + ) + + @property + def display_name(self) -> str: + return self.sdk_function if self.surface is None else f"{self.surface}/{self.sdk_function}" + + @property + def coverage(self) -> Coverage | None: + return self.spec.coverage @dataclass(frozen=True) @@ -60,6 +80,7 @@ class Strategy: description: str directory: Path cases: tuple[HarnessCase, ...] + definition: StrategyDefinition @dataclass @@ -74,6 +95,7 @@ class CaseResult: errors: int = 0 outcomes: dict[str, RunStatus] = field(default_factory=dict) durations: dict[str, float] = field(default_factory=dict) + artifacts: dict[str, tuple[ResultArtifact, ...]] = field(default_factory=dict) @property def total(self) -> int: @@ -83,10 +105,18 @@ class CaseResult: def duration(self) -> float: return sum(self.durations.values()) - def record(self, nodeid: str, status: RunStatus, duration: float = 0.0) -> None: + def record( + self, + nodeid: str, + status: RunStatus, + duration: float = 0.0, + artifacts: tuple[ResultArtifact, ...] = (), + ) -> None: """Record a terminal outcome, allowing teardown errors to replace a pass.""" self.outcomes[nodeid] = status - self.durations[nodeid] = self.durations.get(nodeid, 0.0) + duration + self.add_duration(nodeid, duration) + if artifacts: + self.artifacts[nodeid] = artifacts self.completed = set(self.outcomes) values = tuple(self.outcomes.values()) self.passed = values.count(RunStatus.PASSED) @@ -95,16 +125,25 @@ class CaseResult: self.errors = values.count(RunStatus.ERROR) self.finalize() + def add_duration(self, nodeid: str, duration: float) -> None: + self.durations[nodeid] = self.durations.get(nodeid, 0.0) + duration + def set_initial_status(self) -> None: - if self.case.coverage is Coverage.NOT_APPLICABLE: - self.status = RunStatus.NOT_APPLICABLE - elif not self.case.selectors and not self.case.unit_suite: - self.status = RunStatus.PLANNED - else: - self.status = RunStatus.QUEUED + disposition: Final = self.case.spec.disposition + match disposition: + case CaseDisposition.RUNNABLE: + self.status = RunStatus.QUEUED + return + case CaseDisposition.NOT_IMPLEMENTED: + self.status = RunStatus.NOT_IMPLEMENTED + return + case CaseDisposition.SKIPPED: + self.status = RunStatus.SKIPPED + return + assert_never(disposition) def finalize(self) -> None: - if self.status in {RunStatus.NOT_APPLICABLE, RunStatus.PLANNED}: + if self.status in {RunStatus.NOT_IMPLEMENTED, RunStatus.SKIPPED} and not self.collected: return if not self.collected: self.status = RunStatus.MISSING @@ -118,11 +157,18 @@ class CaseResult: self.status = RunStatus.SKIPPED +@dataclass(frozen=True, slots=True) +class ResultArtifact: + kind: str + body: str + + @dataclass class HarnessRun: results: dict[str, CaseResult] current_nodeid: str | None = None failures: list[tuple[str, str]] = field(default_factory=list) + strategy_durations: dict[str, float] = field(default_factory=dict) started_at: float = field(default_factory=monotonic) finished_at: float | None = None @@ -131,92 +177,20 @@ class HarnessRun: return (self.finished_at or monotonic()) - self.started_at @property - def unique_tests(self) -> int: + def unique_checks(self) -> int: return len( {nodeid for result in self.results.values() for nodeid in result.collected} ) @property - def completed_tests(self) -> int: + def completed_checks(self) -> int: return len( {nodeid for result in self.results.values() for nodeid in result.completed} ) @classmethod - def from_cases(cls, cases: Iterable[HarnessCase]) -> "HarnessRun": + def from_cases(cls, cases: Iterable[HarnessCase]) -> HarnessRun: results = {case.key: CaseResult(case=case) for case in cases} for result in results.values(): result.set_initial_status() return cls(results=results) - - -@dataclass(frozen=True) -class SectionConfidence: - sdk_function: str - verified_strategies: int - required_strategies: int - level: ConfidenceLevel - details: tuple[str, ...] - - @property - def percentage(self) -> int: - if not self.required_strategies: - return 0 - return round(100 * self.verified_strategies / self.required_strategies) - - -def section_confidence( - run: HarnessRun, strategies: Iterable[Strategy] -) -> tuple[SectionConfidence, ...]: - strategy_list = tuple(strategies) - scores: list[SectionConfidence] = [] - sections = tuple(dict.fromkeys((case.surface, case.sdk_function) for strategy in strategy_list for case in strategy.cases)) - for surface, sdk_function in sections: - cases = tuple( - case - for strategy in strategy_list - for case in strategy.cases - if case.sdk_function == sdk_function and case.surface == surface - and case.coverage is not Coverage.NOT_APPLICABLE - ) - verified = 0 - details: list[str] = [] - for case in cases: - result = run.results.get(case.key) - status = result.status if result is not None else RunStatus.NOT_RUN - if status is RunStatus.PASSED: - verified += 1 - details.append( - f"{STATUS_LABELS[status]} {case.strategy_id} ({case.coverage.value})" - ) - required = len(cases) - if required and verified == required: - level = ConfidenceLevel.HIGH - elif verified: - level = ConfidenceLevel.MEDIUM - else: - level = ConfidenceLevel.LOW - scores.append( - SectionConfidence( - sdk_function=sdk_function if surface == "sdk" else f"gateway/{sdk_function}", - verified_strategies=verified, - required_strategies=required, - level=level, - details=tuple(details), - ) - ) - return tuple(scores) - - -STATUS_LABELS = { - RunStatus.NOT_RUN: "·", - RunStatus.QUEUED: "○", - RunStatus.RUNNING: "◉", - RunStatus.PASSED: "✓", - RunStatus.FAILED: "✗", - RunStatus.SKIPPED: "↷", - RunStatus.ERROR: "!", - RunStatus.MISSING: "?", - RunStatus.PLANNED: "—", - RunStatus.NOT_APPLICABLE: "n/a", -} diff --git a/tests/rust-python-harness/shared/reporting/orchestration.py b/tests/rust-python-harness/shared/reporting/orchestration.py index 5e1c0ff57d8..4740f25dc9c 100644 --- a/tests/rust-python-harness/shared/reporting/orchestration.py +++ b/tests/rust-python-harness/shared/reporting/orchestration.py @@ -1,29 +1,31 @@ from __future__ import annotations -from collections.abc import Callable, Sequence +from collections.abc import Sequence from pathlib import Path from time import monotonic -from typing import Final, Protocol +from typing import Final -from .models import HarnessCase, HarnessRun -from .pytest_runner import UpdateCallback +from .models import HarnessRun, Strategy +from .strategy import StrategyRunner, UpdateCallback + +__all__ = ["StrategyRunner", "run_strategies"] -class StrategyRunner(Protocol): - def __call__( - self, - cases: Sequence[HarnessCase], - repo_root: Path, - on_update: UpdateCallback, - pytest_args: Sequence[str] = (), - ) -> tuple[int, HarnessRun]: ... - - -def combine_reports(reports: Sequence[HarnessRun]) -> HarnessRun: +def combine_reports( + reports: Sequence[HarnessRun], + *, + timed_reports: Sequence[HarnessRun] | None = None, +) -> HarnessRun: + duration_sources: Final = reports if timed_reports is None else timed_reports return HarnessRun( results={key: result for report in reports for key, result in report.results.items()}, current_nodeid=next((report.current_nodeid for report in reversed(reports) if report.current_nodeid), None), failures=[failure for report in reports for failure in report.failures], + strategy_durations={ + strategy_id: report.duration + for report in duration_sources + for strategy_id in {result.case.strategy_id for result in report.results.values()} + }, started_at=min((report.started_at for report in reports), default=monotonic()), finished_at=( max((report.finished_at for report in reports if report.finished_at is not None), default=None) @@ -34,32 +36,38 @@ def combine_reports(reports: Sequence[HarnessRun]) -> HarnessRun: def run_strategies( - cases: Sequence[HarnessCase], + strategies: Sequence[Strategy], repo_root: Path, on_update: UpdateCallback, - pytest_args: Sequence[str], - resolve_runner: Callable[[str], StrategyRunner], + runner_args: Sequence[str] = (), ) -> tuple[int, HarnessRun]: - strategy_ids: Final = tuple(dict.fromkeys(case.strategy_id for case in cases)) + cases: Final = tuple(case for strategy in strategies for case in strategy.cases) def execute( - remaining: tuple[str, ...], reports: tuple[HarnessRun, ...], codes: tuple[int, ...] + remaining: tuple[Strategy, ...], reports: tuple[HarnessRun, ...], codes: tuple[int, ...] ) -> tuple[int, HarnessRun]: if not remaining: combined: Final = combine_reports(reports) on_update(combined) return next((code for code in codes if code), 0), combined - strategy_id, *tail = remaining - selected: Final = tuple(case for case in cases if case.strategy_id == strategy_id) - pending: Final = HarnessRun.from_cases(case for case in cases if case.strategy_id in tail) - code, report = resolve_runner(strategy_id)( + strategy, *tail = remaining + selected: Final = tuple(case for case in cases if case.strategy_id == strategy.id) + pending: Final = HarnessRun.from_cases( + case for case in cases if case.strategy_id in {later.id for later in tail} + ) + code, report = strategy.definition.run( selected, repo_root, - lambda current: on_update(combine_reports((*reports, current, pending))), - pytest_args, + lambda current: on_update( + combine_reports( + (*reports, current, pending), + timed_reports=(*reports, current), + ) + ), + runner_args, ) if code in {2, 3, 4}: return code, combine_reports((*reports, report, pending)) return execute(tuple(tail), (*reports, report), (*codes, code)) - return execute(strategy_ids, (), ()) + return execute(tuple(strategies), (), ()) diff --git a/tests/rust-python-harness/shared/reporting/pytest_runner.py b/tests/rust-python-harness/shared/reporting/pytest_runner.py deleted file mode 100644 index a7e73308f30..00000000000 --- a/tests/rust-python-harness/shared/reporting/pytest_runner.py +++ /dev/null @@ -1,172 +0,0 @@ -from __future__ import annotations - -import os -from collections.abc import Callable, Sequence -from pathlib import Path -from time import monotonic -from typing import Final - -import pytest - -from .models import CaseResult, HarnessCase, HarnessRun, RunStatus - -UpdateCallback = Callable[[HarnessRun], None] - - -def selector_matches_node(selector: str, nodeid: str) -> bool: - normalized_selector = selector.replace("\\", "/") - normalized_nodeid = nodeid.replace("\\", "/") - if normalized_selector.endswith("/"): - return normalized_nodeid.startswith(normalized_selector) - if "::" in normalized_selector: - return normalized_nodeid == normalized_selector or normalized_nodeid.startswith( - f"{normalized_selector}[" - ) - return normalized_nodeid == normalized_selector or normalized_nodeid.startswith( - f"{normalized_selector}::" - ) - - -def selector_path(selector: str) -> Path: - return Path(selector.split("::", 1)[0]) - - -def runnable_selectors( - cases: Sequence[HarnessCase], repo_root: Path -) -> tuple[str, ...]: - selectors = { - selector - for case in cases - for selector in case.selectors - if (repo_root / selector_path(selector)).exists() - } - return tuple(sorted(selectors)) - - -class HarnessPytestPlugin: - def __init__(self, run: HarnessRun, on_update: UpdateCallback) -> None: - self.run = run - self.on_update = on_update - self.node_to_results: dict[str, list[CaseResult]] = {} - - def _notify(self) -> None: - self.on_update(self.run) - - def pytest_collection_modifyitems(self, items: list[pytest.Item]) -> None: - for item in items: - matched_results: list[CaseResult] = [] - for result in self.run.results.values(): - if any( - selector_matches_node(selector, item.nodeid) - for selector in result.case.selectors - ): - result.collected.add(item.nodeid) - matched_results.append(result) - if matched_results: - self.node_to_results[item.nodeid] = matched_results - for result in self.run.results.values(): - if result.status is RunStatus.QUEUED and not result.collected: - result.status = RunStatus.MISSING - self._notify() - - def pytest_runtest_logstart( - self, nodeid: str, location: tuple[str, int | None, str] - ) -> None: - del location - self.run.current_nodeid = nodeid - for result in self.node_to_results.get(nodeid, []): - if result.status not in {RunStatus.FAILED, RunStatus.ERROR}: - result.status = RunStatus.RUNNING - self._notify() - - def pytest_runtest_logreport(self, report: pytest.TestReport) -> None: - if report.when not in {"setup", "call", "teardown"}: - return - results = self.node_to_results.get(report.nodeid, []) - if not results: - return - - terminal = report.when == "call" or report.failed or report.skipped - if not terminal: - for result in results: - result.durations[report.nodeid] = ( - result.durations.get(report.nodeid, 0.0) + report.duration - ) - return - for result in results: - if report.when == "teardown" and not report.failed: - result.durations[report.nodeid] = ( - result.durations.get(report.nodeid, 0.0) + report.duration - ) - continue - if report.skipped: - status = RunStatus.SKIPPED - elif report.failed and report.when in {"setup", "teardown"}: - status = RunStatus.ERROR - elif report.failed: - status = RunStatus.FAILED - else: - status = RunStatus.PASSED - result.record(report.nodeid, status, report.duration) - if report.failed: - failure = (report.nodeid, str(report.longrepr)) - if failure not in self.run.failures: - self.run.failures.append(failure) - self._notify() - - def pytest_sessionfinish( - self, session: pytest.Session, exitstatus: int | pytest.ExitCode - ) -> None: - del session, exitstatus - self.run.current_nodeid = None - self.run.finished_at = monotonic() - for result in self.run.results.values(): - result.finalize() - self._notify() - - -def run_pytest( - cases: Sequence[HarnessCase], - repo_root: Path, - on_update: UpdateCallback, - pytest_args: Sequence[str] = (), -) -> tuple[int, HarnessRun]: - run = HarnessRun.from_cases(cases) - selectors = runnable_selectors(cases, repo_root) - if not selectors: - for result in run.results.values(): - result.finalize() - run.finished_at = monotonic() - on_update(run) - has_missing_test = any( - result.status is RunStatus.MISSING for result in run.results.values() - ) - exit_code = ( - int(pytest.ExitCode.TESTS_FAILED) - if has_missing_test - else int(pytest.ExitCode.OK) - ) - return exit_code, run - - plugin = HarnessPytestPlugin(run=run, on_update=on_update) - args: Final = (*selectors, "-q", "--tb=no", "--no-summary", "-o", "consider_namespace_packages=true", *pytest_args) - previous_directory = Path.cwd() - try: - os.chdir(repo_root) - exit_code = int(pytest.main(list(args), plugins=[plugin])) - finally: - os.chdir(previous_directory) - for result in run.results.values(): - missing = tuple( - selector for selector in result.case.selectors - if not any(selector_matches_node(selector, node) for node in result.collected) - ) - if missing: - result.status = RunStatus.MISSING - run.failures.extend((selector, "Configured selector collected no tests") for selector in missing) - on_update(run) - if exit_code == 0 and any( - result.status is RunStatus.MISSING for result in run.results.values() - ): - exit_code = int(pytest.ExitCode.TESTS_FAILED) - return exit_code, run diff --git a/tests/rust-python-harness/shared/reporting/rendering.py b/tests/rust-python-harness/shared/reporting/rendering.py new file mode 100644 index 00000000000..217caa48526 --- /dev/null +++ b/tests/rust-python-harness/shared/reporting/rendering.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass +from typing import Final, Protocol, assert_never + +from .models import CaseDisposition, CaseResult + + +@dataclass(frozen=True, slots=True) +class ReportSection: + title: str + blocks: tuple[str, ...] + + +class StrategyRenderer(Protocol): + def __call__(self, results: Sequence[CaseResult]) -> tuple[ReportSection, ...]: ... + + +def render_case_outcome(result: CaseResult) -> str: + prefix: Final = f"- {result.case.display_name}: {result.status.value}" + spec: Final = result.case.spec + match spec.disposition: + case CaseDisposition.RUNNABLE: + progress: Final = f", {len(result.completed)}/{result.total} checks" if result.total else "" + return f"{prefix}{progress}, {spec.coverage.value} coverage" + case CaseDisposition.NOT_IMPLEMENTED | CaseDisposition.SKIPPED: + return f"{prefix}, {spec.reason}" + assert_never(spec.disposition) diff --git a/tests/rust-python-harness/shared/reporting/strategy.py b/tests/rust-python-harness/shared/reporting/strategy.py new file mode 100644 index 00000000000..7e76f035e20 --- /dev/null +++ b/tests/rust-python-harness/shared/reporting/strategy.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +from collections.abc import Callable, Sequence +from dataclasses import dataclass +from pathlib import Path +from typing import Annotated, Literal, Protocol, TypeAlias + +from pydantic import BaseModel, ConfigDict, StringConstraints + +from .models import CaseDisposition, Coverage, HarnessCase, HarnessRun, SdkFunction, Surface +from .rendering import StrategyRenderer + +UpdateCallback: TypeAlias = Callable[[HarnessRun], None] +NonBlankString: TypeAlias = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)] + + +class SuiteCaseSpec(BaseModel): + model_config = ConfigDict(frozen=True, extra="forbid") + + disposition: Literal[CaseDisposition.RUNNABLE] = CaseDisposition.RUNNABLE + coverage: Coverage + suite: NonBlankString + note: str = "" + + +class ModuleCaseSpec(BaseModel): + model_config = ConfigDict(frozen=True, extra="forbid") + + disposition: Literal[CaseDisposition.RUNNABLE] = CaseDisposition.RUNNABLE + coverage: Coverage + module: NonBlankString + note: str = "" + + +class NotImplementedCaseSpec(BaseModel): + model_config = ConfigDict(frozen=True, extra="forbid") + + disposition: Literal[CaseDisposition.NOT_IMPLEMENTED] = CaseDisposition.NOT_IMPLEMENTED + coverage: None = None + reason: NonBlankString + + +class SkippedCaseSpec(BaseModel): + model_config = ConfigDict(frozen=True, extra="forbid") + + disposition: Literal[CaseDisposition.SKIPPED] = CaseDisposition.SKIPPED + coverage: None = None + reason: NonBlankString + + +RunnableCaseSpec: TypeAlias = SuiteCaseSpec | ModuleCaseSpec +UnavailableCaseSpec: TypeAlias = NotImplementedCaseSpec | SkippedCaseSpec +CaseSpec: TypeAlias = RunnableCaseSpec | UnavailableCaseSpec + + +@dataclass(frozen=True, slots=True) +class CaseDefinition: + sdk_function: SdkFunction + spec: CaseSpec + surface: Surface | None = None + + +@dataclass(frozen=True, slots=True) +class RunnerArgumentDefinition: + option: str + help: str + metavar: str = "ARG" + + +class StrategyRunner(Protocol): + def __call__( + self, + cases: Sequence[HarnessCase], + repo_root: Path, + on_update: UpdateCallback, + runner_args: Sequence[str] = (), + ) -> tuple[int, HarnessRun]: ... + + +@dataclass(frozen=True, slots=True) +class StrategyDefinition: + id: str + order: int + label: str + description: str + directory: Path + runnable_spec: type[SuiteCaseSpec] | type[ModuleCaseSpec] + cases: tuple[CaseDefinition, ...] + run: StrategyRunner + render: StrategyRenderer + surfaces: tuple[Surface, ...] = () + runner_argument: RunnerArgumentDefinition | None = None diff --git a/tests/rust-python-harness/shared/reporting/test_orchestration.py b/tests/rust-python-harness/shared/reporting/test_orchestration.py index 8aea6d67e6e..7696ab781e2 100644 --- a/tests/rust-python-harness/shared/reporting/test_orchestration.py +++ b/tests/rust-python-harness/shared/reporting/test_orchestration.py @@ -1,47 +1,161 @@ from __future__ import annotations +import logging +from collections.abc import Sequence from pathlib import Path +from time import monotonic from typing import Final -from .models import Coverage, HarnessCase, RunStatus +from .models import CaseResult, Coverage, HarnessCase, HarnessRun, RunStatus, Strategy from .orchestration import run_strategies -from .pytest_runner import run_pytest +from .rendering import ReportSection, StrategyRenderer, render_case_outcome +from .strategy import ( + CaseDefinition, + ModuleCaseSpec, + NotImplementedCaseSpec, + StrategyDefinition, + UpdateCallback, +) +from .ui import HarnessOutputFilter, final_report -def test_combines_independent_strategy_reports_and_keeps_failures(tmp_path: Path) -> None: - (tmp_path / "test_first.py").write_text("def test_first():\n assert 1 == 2\n") - (tmp_path / "test_second.py").write_text("def test_second():\n assert True\n") - cases: Final = tuple( - HarnessCase( - strategy_id=name, - strategy_label=name, - sdk_function="ocr", - coverage=Coverage.COMPLETE, - selectors=(f"test_{name}.py",), - ) - for name in ("first", "second") +def _run_cases( + cases: Sequence[HarnessCase], + repo_root: Path, + on_update: UpdateCallback, + runner_args: Sequence[str] = (), +) -> tuple[int, HarnessRun]: + del repo_root, runner_args + run: Final = HarnessRun.from_cases(cases) + for case in cases: + _record_case(run, case, on_update) + run.finished_at = monotonic() + return int(bool(run.failures)), run + + +def _record_case(run: HarnessRun, case: HarnessCase, on_update: UpdateCallback) -> None: + result: Final = run.results[case.key] + nodeid: Final = f"check:{case.strategy_id}:{case.sdk_function}" + result.collected.add(nodeid) + failed: Final = isinstance(case.spec, ModuleCaseSpec) and case.spec.module == "fail" + result.record(nodeid, RunStatus.FAILED if failed else RunStatus.PASSED) + if failed: + run.failures.append((nodeid, "comparison failed")) + on_update(run) + + +def _render_test_results(results: Sequence[CaseResult]) -> tuple[ReportSection, ...]: + return (ReportSection("Test outcomes", tuple(render_case_outcome(result) for result in results)),) + + +def _strategy(name: str, module: str, *, render: StrategyRenderer = _render_test_results) -> Strategy: + case_definition: Final = CaseDefinition("ocr", ModuleCaseSpec(coverage=Coverage.COMPLETE, module=module)) + definition: Final = StrategyDefinition( + id=name, + order=1, + label=name, + description="Example strategy", + directory=Path.cwd(), + runnable_spec=ModuleCaseSpec, + cases=(case_definition,), + run=_run_cases, + render=render, ) - code, report = run_strategies(cases, tmp_path, lambda _: None, (), lambda _: run_pytest) + case: Final = HarnessCase( + strategy_id=name, + strategy_label=name, + sdk_function="ocr", + spec=case_definition.spec, + ) + return Strategy(1, name, name, "", Path.cwd(), (case,), definition) + + +def test_combines_strategy_reports_and_delegates_rendering() -> None: + strategies: Final = (_strategy("first", "fail"), _strategy("second", "pass")) + + code, report = run_strategies(strategies, Path.cwd(), lambda _: None) + assert code == 1 assert report.results["first:ocr"].status is RunStatus.FAILED assert report.results["second:ocr"].status is RunStatus.PASSED - assert report.completed_tests == 2 - assert len(report.failures) == 1 - assert "assert 1 == 2" in report.failures[0][1] - assert "terminalreporter" not in report.failures[0][1] + assert report.completed_checks == 2 + rendered: Final = final_report(report, code, strategies) + assert "Result: FAILED" in rendered + assert rendered.count("Test outcomes") == 2 + assert "- ocr: failed, 1/1 checks, complete coverage" in rendered + assert "- ocr: passed, 1/1 checks, complete coverage" in rendered + assert "Failures (showing 1 of 1)" in rendered + assert "Port confidence" not in rendered + assert "Slowest tests" not in rendered -def test_missing_selector_cannot_hide_behind_a_passing_surface(tmp_path: Path) -> None: - (tmp_path / "test_present.py").write_text("def test_present():\n assert True\n") - case: Final = HarnessCase( - strategy_id="e2e_parity", - strategy_label="End-to-end parity", - sdk_function="ocr", - surface="gateway", - coverage=Coverage.PARTIAL, - selectors=("test_present.py", "test_missing.py"), +def test_strategy_can_replace_the_generic_result_view() -> None: + def render_custom(results: Sequence[CaseResult]) -> tuple[ReportSection, ...]: + del results + return (ReportSection("Custom comparison", ("domain-owned diff",)),) + + strategy: Final = _strategy("custom", "pass", render=render_custom) + code, report = run_strategies((strategy,), Path.cwd(), lambda _: None) + + rendered: Final = final_report(report, code, (strategy,)) + assert "Custom comparison\ndomain-owned diff" in rendered + assert "sdk/ocr" not in rendered + + +def test_report_separates_successful_execution_from_incomplete_coverage() -> None: + runnable: Final = _strategy("mixed", "pass") + unavailable: Final = HarnessCase( + strategy_id="mixed", + strategy_label="mixed", + sdk_function="messages", + spec=NotImplementedCaseSpec(reason="No Messages case is registered."), ) - code, report = run_pytest((case,), tmp_path, lambda _: None) - assert code == 1 - assert report.results["e2e_parity:gateway:ocr"].status is RunStatus.MISSING - assert ("test_missing.py", "Configured selector collected no tests") in report.failures + code, executed = run_strategies((runnable,), Path.cwd(), lambda _: None) + unavailable_run: Final = HarnessRun.from_cases((unavailable,)) + combined: Final = HarnessRun( + results={**executed.results, **unavailable_run.results}, + started_at=executed.started_at, + finished_at=executed.finished_at, + ) + + rendered: Final = final_report(combined, code, (runnable,)) + + assert code == 0 + assert "Result: PASSED" in rendered + assert "Harness support: 1/2 cases implemented" in rendered + assert "Cases: 2 selected, 1 not implemented, 0 skipped" in rendered + + +def test_harness_output_filter_suppresses_expected_harness_warnings() -> None: + output_filter: Final = HarnessOutputFilter() + ocr_cost_warning: Final = logging.LogRecord( + "LiteLLM", + logging.WARNING, + "/repo/litellm/cost_calculator.py", + 1953, + "OCR cost: model=%s has no pricing", + ("example",), + None, + ) + other_warning: Final = logging.LogRecord( + "LiteLLM", + logging.WARNING, + "/repo/litellm/main.py", + 1, + "Provider warning", + (), + None, + ) + loop_warning: Final = logging.LogRecord( + "LiteLLM", + logging.WARNING, + "/repo/litellm/litellm_core_utils/logging_worker.py", + 129, + "LoggingWorker: event loop changed; carried %d pending and revived %d dequeued logging task(s) onto the new loop", + (1, 0), + None, + ) + + assert output_filter.filter(ocr_cost_warning) is False + assert output_filter.filter(loop_warning) is False + assert output_filter.filter(other_warning) is True diff --git a/tests/rust-python-harness/shared/reporting/ui.py b/tests/rust-python-harness/shared/reporting/ui.py index 3807af8c53b..e4b1b7b0442 100644 --- a/tests/rust-python-harness/shared/reporting/ui.py +++ b/tests/rust-python-harness/shared/reporting/ui.py @@ -1,45 +1,39 @@ from __future__ import annotations -import os -import shlex -import sys +import logging from collections.abc import Sequence from contextlib import AbstractContextManager -from pathlib import Path -from typing import Any +from textwrap import indent +from types import TracebackType +from typing import TYPE_CHECKING, Final -from .models import ( - Coverage, - HarnessRun, - RunStatus, - Strategy, - section_confidence, -) +from litellm._logging import handler as litellm_log_handler -STATUS_GLYPHS = { - RunStatus.NOT_RUN: "·", - RunStatus.QUEUED: "○", - RunStatus.RUNNING: "◉", - RunStatus.PASSED: "✓", - RunStatus.FAILED: "✗", - RunStatus.SKIPPED: "↷", - RunStatus.ERROR: "!", - RunStatus.MISSING: "?", - RunStatus.PLANNED: "—", - RunStatus.NOT_APPLICABLE: "n/a", -} +from .models import HarnessRun, RunStatus, Strategy +from .rendering import ReportSection -STATUS_STYLES = { - RunStatus.QUEUED: "dim", - RunStatus.RUNNING: "bold cyan", - RunStatus.PASSED: "bold green", - RunStatus.FAILED: "bold red", - RunStatus.SKIPPED: "yellow", - RunStatus.ERROR: "bold red", - RunStatus.MISSING: "magenta", - RunStatus.PLANNED: "dim", - RunStatus.NOT_APPLICABLE: "dim", -} +if TYPE_CHECKING: + from rich.live import Live + + +class HarnessOutputFilter(logging.Filter): + def filter(self, record: logging.LogRecord) -> bool: + noisy_prefixes: Final = ( + "OCR cost:", + "LoggingWorker: event loop changed;", + ) + return not (record.name == "LiteLLM" and record.getMessage().startswith(noisy_prefixes)) + + +_HARNESS_OUTPUT_FILTER: Final = HarnessOutputFilter() + + +def _start_output_filtering() -> None: + litellm_log_handler.addFilter(_HARNESS_OUTPUT_FILTER) + + +def _stop_output_filtering() -> None: + litellm_log_handler.removeFilter(_HARNESS_OUTPUT_FILTER) def _format_duration(seconds: float) -> str: @@ -50,250 +44,221 @@ def _format_duration(seconds: float) -> str: return f"{int(seconds // 60)}m {seconds % 60:.0f}s" -def _rerun_command(nodeid: str) -> str: - if nodeid.startswith("unit-suite:"): - return "uv run python -m tests.rust-python-harness.strategies.unit_tests.runner --plain" - return f"poetry run pytest {shlex.quote(nodeid)} -q -o consider_namespace_packages=true" - - def _summary(run: HarnessRun) -> tuple[int, int, int, int]: outcomes: dict[str, RunStatus] = {} for result in run.results.values(): outcomes.update(result.outcomes) + values: Final = tuple(outcomes.values()) return ( - list(outcomes.values()).count(RunStatus.PASSED), - list(outcomes.values()).count(RunStatus.FAILED), - list(outcomes.values()).count(RunStatus.ERROR), - list(outcomes.values()).count(RunStatus.SKIPPED), + values.count(RunStatus.PASSED), + values.count(RunStatus.FAILED), + values.count(RunStatus.ERROR), + values.count(RunStatus.SKIPPED), ) -def _cell_text(run: HarnessRun, strategy_id: str, sdk_function: str, surface: str = "sdk") -> tuple[str, str]: - key = f"{strategy_id}:{sdk_function}" if surface == "sdk" else f"{strategy_id}:gateway:{sdk_function}" - result = run.results.get(key) - if result is None: - return "", "" - counts = "" - if result.total: - counts = f" {len(result.completed)}/{result.total}" - coverage = " ◐" if result.case.coverage is Coverage.PARTIAL else "" - return f"{STATUS_GLYPHS[result.status]}{counts}{coverage}", STATUS_STYLES.get( - result.status, "" +def _strategy_state(statuses: tuple[RunStatus, ...], outcomes: tuple[RunStatus, ...]) -> str: + for status in ( + RunStatus.ERROR, + RunStatus.FAILED, + RunStatus.MISSING, + RunStatus.RUNNING, + RunStatus.QUEUED, + ): + if status in statuses: + return status.value + if RunStatus.NOT_IMPLEMENTED in statuses: + return RunStatus.NOT_IMPLEMENTED.value + if outcomes and all(outcome is RunStatus.SKIPPED for outcome in outcomes): + return RunStatus.SKIPPED.value + if statuses and all(status is RunStatus.SKIPPED for status in statuses): + return RunStatus.SKIPPED.value + for status in (RunStatus.PASSED, RunStatus.SKIPPED): + if status in statuses: + return status.value + return RunStatus.NOT_RUN.value + + +def _strategy_line(strategy: Strategy, run: HarnessRun) -> str: + results: Final = tuple(run.results[case.key] for case in strategy.cases if case.key in run.results) + outcomes: dict[str, RunStatus] = {} + collected: set[str] = set() + for result in results: + outcomes.update(result.outcomes) + collected.update(result.collected) + values: Final = tuple(outcomes.values()) + statuses: Final = tuple(result.status for result in results) + state: Final = _strategy_state(statuses, values) + completed: Final = len(outcomes) + total: Final = len(collected) + progress: Final = f", {completed}/{total} checks" if total else "" + counts: Final = ( + f", {values.count(RunStatus.PASSED)} passed, " + f"{values.count(RunStatus.FAILED) + values.count(RunStatus.ERROR)} failed, " + f"{values.count(RunStatus.SKIPPED)} skipped" + if total + else "" ) + duration: Final = run.strategy_durations.get(strategy.id, 0.0) + return f"- {strategy.label}: {state}{progress}{counts}, {_format_duration(duration)}" + + +def _rendered_sections(run: HarnessRun, strategies: Sequence[Strategy]) -> tuple[ReportSection, ...]: + return tuple( + section + for strategy in strategies + if any(case.key in run.results for case in strategy.cases) + for section in strategy.definition.render( + tuple(run.results[case.key] for case in strategy.cases if case.key in run.results) + ) + ) + + +def _format_section(section: ReportSection) -> str: + return f"{section.title}\n" + ("\n\n".join(section.blocks) or "- No results") + + +def _run_result(run: HarnessRun, exit_code: int) -> str: + statuses: Final = tuple(result.status for result in run.results.values()) + if exit_code: + return "FAILED" + if not statuses or all(status is RunStatus.NOT_IMPLEMENTED for status in statuses): + return "NOT RUN" + if all(status is RunStatus.SKIPPED for status in statuses): + return "SKIPPED" + return "PASSED" + + +def final_report(run: HarnessRun, exit_code: int, strategies: Sequence[Strategy]) -> str: + passed, failed, errors, skipped = _summary(run) + run_result: Final = _run_result(run, exit_code) + statuses: Final = tuple(case_result.status for case_result in run.results.values()) + not_implemented: Final = statuses.count(RunStatus.NOT_IMPLEMENTED) + implemented: Final = len(statuses) - not_implemented + skipped_cells: Final = statuses.count(RunStatus.SKIPPED) + failure_lines: Final = tuple( + f"{index}. {nodeid}\n{indent(detail.strip(), ' ')}" + for index, (nodeid, detail) in enumerate(run.failures[:5], start=1) + ) + rendered: Final = tuple(_format_section(section) for section in _rendered_sections(run, strategies)) + summary: Final = ( + "Rust <-> Python parity report\n\n" + f"Result: {run_result}\n" + f"Harness support: {implemented}/{len(statuses)} cases implemented\n" + f"Cases: {len(statuses)} selected, {not_implemented} not implemented, {skipped_cells} skipped\n" + f"Checks: {run.completed_checks}/{run.unique_checks} completed, {passed} passed, " + f"{failed} failed, {errors} errors, {skipped} skipped\n" + f"Duration: {_format_duration(run.duration)}\n" + f"Exit code: {exit_code}" + ) + failures: Final = ( + (f"Failures (showing {len(failure_lines)} of {len(run.failures)})\n" + "\n\n".join(failure_lines)) + if failure_lines + else "" + ) + return "\n\n".join((summary, *rendered, *((failures,) if failures else ()))) class RichDashboard(AbstractContextManager["RichDashboard"]): - def __init__( - self, - strategies: Sequence[Strategy], - confidence_strategies: Sequence[Strategy], - ) -> None: + def __init__(self, strategies: Sequence[Strategy]) -> None: from rich.console import Console from rich.live import Live self.strategies = strategies - self.confidence_strategies = confidence_strategies self.console = Console() - self.live: Any = Live( - console=self.console, refresh_per_second=12, transient=False - ) + self.live: Live = Live(console=self.console, refresh_per_second=12, transient=True) + self._live_active = False - def _table(self, run: HarnessRun) -> Any: - from rich import box - from rich.table import Table - from rich.text import Text - - columns = tuple(dict.fromkeys((case.surface, case.sdk_function) for strategy in self.strategies for case in strategy.cases)) - narrow = self.console.width < 96 - if narrow: - table = Table(box=box.SIMPLE_HEAVY, expand=True, show_header=False) - table.add_column("Strategy", ratio=3) - table.add_column("Results", ratio=5) - for strategy in self.strategies: - values = [] - for surface, sdk_function in columns: - value, style = _cell_text(run, strategy.id, sdk_function, surface) - if value: - values.append( - Text.assemble((f"{surface}/{sdk_function} ", "dim"), (value, style)) - ) - table.add_row(strategy.label, Text(" ").join(values)) - return table - - table = Table(box=box.ROUNDED, expand=True, title="Strategy × API") - table.add_column("Strategy", ratio=3) - for surface, label in columns: - table.add_column(label if surface == "sdk" else f"gateway/{label}", justify="center", ratio=1) - for strategy in self.strategies: - cells = [] - for surface, sdk_function in columns: - value, style = _cell_text(run, strategy.id, sdk_function, surface) - cells.append(Text(value, style=style)) - table.add_row(strategy.label, *cells) - return table - - def __enter__(self) -> "RichDashboard": + def __enter__(self) -> RichDashboard: + _start_output_filtering() self.live.__enter__() + self._live_active = True return self - def __exit__(self, *args: object) -> None: - self.live.__exit__(*args) + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: + _stop_output_filtering() + if self._live_active: + self.live.__exit__(exc_type, exc_value, traceback) + self._live_active = False def update(self, run: HarnessRun) -> None: from rich.markup import escape - from rich.panel import Panel - active = run.current_nodeid or "Waiting for test events…" - if len(active) > max(40, self.console.width - 16): - active = f"…{active[-(self.console.width - 17):]}" + active: Final = run.current_nodeid or "Waiting for test events..." + available_width: Final = max(40, self.console.width - 10) + visible_active: Final = active if len(active) <= available_width else f"...{active[-(available_width - 3) :]}" passed, failed, errors, skipped = _summary(run) - progress = ( - f"[bold]{run.completed_tests}/{run.unique_tests}[/bold] tests " - f"[green]{passed} passed[/green] [red]{failed + errors} failed[/red] " - f"[yellow]{skipped} skipped[/yellow] [dim]{_format_duration(run.duration)}[/dim]" - ) - legend = "✓ pass ✗ fail ! error ↷ skip\n? configured test missing — planned ◐ partial coverage" + total: Final = run.unique_checks + percentage: Final = round(100 * run.completed_checks / total) if total else 0 + strategy_lines: Final = "\n".join(escape(_strategy_line(strategy, run)) for strategy in self.strategies) self.live.update( - Panel( - self._table(run), - title="⚡ Rust ↔ Python parity lab", - subtitle=f"{progress}\n[dim]{escape(active)}[/dim]\n{legend}", - border_style="cyan", - ) + "[bold]Running Rust <-> Python parity[/bold]\n" + f"Progress: [bold]{run.completed_checks}/{total} ({percentage}%)[/bold] | " + f"[green]{passed} passed[/green] | [red]{failed + errors} failed[/red] | " + f"[yellow]{skipped} skipped[/yellow] | [dim]{_format_duration(run.duration)}[/dim]\n" + f"Strategies:\n{strategy_lines}\n" + f"Current: [dim]{escape(visible_active)}[/dim]" ) def finish(self, run: HarnessRun, exit_code: int) -> None: - self.update(run) - if run.failures: - from rich.markup import escape - from rich.panel import Panel - - for nodeid, detail in run.failures[:5]: - rerun = _rerun_command(nodeid) - self.console.print( - Panel( - f"{escape(detail)}\n\n[bold]Rerun just this test[/bold]\n" - f"[cyan]{escape(rerun)}[/cyan]", - title=f"✗ {escape(nodeid)}", - border_style="red", - ) - ) - durations: dict[str, float] = {} - for result in run.results.values(): - for nodeid, duration in result.durations.items(): - durations[nodeid] = max(duration, durations.get(nodeid, 0.0)) - if durations: - slow = sorted(durations.items(), key=lambda item: item[1], reverse=True)[:3] - self.console.print( - "[bold]Slowest tests[/bold] " - + " • ".join( - f"{Path(nodeid).name} [dim]{_format_duration(duration)}[/dim]" - for nodeid, duration in slow - ) - ) - from rich import box - from rich.table import Table - - confidence_table = Table( - title="Port confidence by API", box=box.ROUNDED, expand=True - ) - confidence_table.add_column("SDK section") - confidence_table.add_column("Score", justify="right") - confidence_table.add_column("Confidence") - confidence_table.add_column("Strategy evidence", ratio=4) - confidence_styles = {"HIGH": "green", "MEDIUM": "yellow", "LOW": "red"} - for score in section_confidence(run, self.confidence_strategies): - confidence_table.add_row( - score.sdk_function, - f"{score.verified_strategies}/{score.required_strategies} {score.percentage}%", - f"[{confidence_styles[score.level.value]}]{score.level.value}[/]", - " ".join(score.details), - ) - self.console.print(confidence_table) - self.console.print( - "[dim]Score = required strategies with passing evidence. " - "LOC coverage remains a separate report.[/dim]" - ) - style = "green" if exit_code == 0 else "red" - self.console.print( - f"[{style}]Harness finished in {_format_duration(run.duration)} " - f"(exit {exit_code})[/{style}]" - ) + if self._live_active: + self.live.stop() + self._live_active = False + print(final_report(run, exit_code, self.strategies), flush=True) # noqa: T201 # CLI output class PlainDashboard(AbstractContextManager["PlainDashboard"]): - def __init__( - self, - strategies: Sequence[Strategy], - confidence_strategies: Sequence[Strategy], - ) -> None: + def __init__(self, strategies: Sequence[Strategy]) -> None: self.strategies = strategies - self.confidence_strategies = confidence_strategies self._seen: dict[str, tuple[RunStatus, int]] = {} - def __enter__(self) -> "PlainDashboard": - print("Rust <-> Python SDK parity harness", flush=True) + def __enter__(self) -> PlainDashboard: + _start_output_filtering() + print("Running Rust <-> Python parity", flush=True) # noqa: T201 # CLI output return self - def __exit__(self, *args: object) -> None: - return None + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: + del exc_type, exc_value, traceback + _stop_output_filtering() def update(self, run: HarnessRun) -> None: for key, result in run.results.items(): - state = (result.status, len(result.completed)) - if self._seen.get(key) != state: - self._seen[key] = state - progress = ( - f" {len(result.completed)}/{result.total}" if result.total else "" - ) - print( - f"{STATUS_GLYPHS[result.status]} {key}: {result.status.value}{progress}", - flush=True, - ) + self._update_result(key, result.case.display_name, result.status, len(result.completed), result.total) + + def _update_result(self, key: str, label: str, status: RunStatus, completed: int, total: int) -> None: + state: Final = (status, completed) + previous: Final = self._seen.get(key) + self._seen[key] = state + visible: Final = status not in { + RunStatus.NOT_RUN, + RunStatus.QUEUED, + RunStatus.NOT_IMPLEMENTED, + RunStatus.SKIPPED, + } + should_print: Final = visible and ( + previous is None or previous[0] is not status or (completed > 0 and completed % 25 == 0) + ) + if should_print: + progress: Final = f" {completed}/{total}" if total else "" + print( # noqa: T201 # CLI output + f"{label}: {status.value}{progress}", flush=True + ) def finish(self, run: HarnessRun, exit_code: int) -> None: - self.update(run) - passed, failed, errors, skipped = _summary(run) - print( - f"Summary: {passed} passed, {failed} failed, {errors} errors, " - f"{skipped} skipped in {_format_duration(run.duration)}", - flush=True, + print( # noqa: T201 # CLI output + f"\n{final_report(run, exit_code, self.strategies)}", flush=True ) - for nodeid, detail in run.failures[:5]: - print(f"{nodeid}: {detail}", flush=True) - print(f"Rerun: {_rerun_command(nodeid)}", flush=True) - print("Port confidence by API", flush=True) - for score in section_confidence(run, self.confidence_strategies): - print( - f" {score.sdk_function:12} " - f"{score.verified_strategies}/{score.required_strategies} " - f"{score.percentage:3}% {score.level.value:6} " - f"{' | '.join(score.details)}", - flush=True, - ) - print( - " Score = required strategies with passing evidence; LOC is reported separately.", - flush=True, - ) - print(f"Harness finished with exit code {exit_code}", flush=True) -def make_dashboard( - strategies: Sequence[Strategy], - plain: bool = False, - confidence_strategies: Sequence[Strategy] | None = None, -) -> RichDashboard | PlainDashboard: - confidence_strategies = confidence_strategies or strategies - interactive_terminal = ( - sys.stdout.isatty() - and not os.environ.get("CI") - and os.environ.get("TERM") != "dumb" - ) - if not plain and interactive_terminal: - try: - import rich # noqa: F401 - - return RichDashboard(strategies, confidence_strategies) - except ImportError: - pass - return PlainDashboard(strategies, confidence_strategies) +def make_dashboard(strategies: Sequence[Strategy]) -> PlainDashboard: + return PlainDashboard(strategies) diff --git a/tests/rust-python-harness/shared/test_native_build.py b/tests/rust-python-harness/shared/test_native_build.py new file mode 100644 index 00000000000..f3e5aead846 --- /dev/null +++ b/tests/rust-python-harness/shared/test_native_build.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +import os +from types import SimpleNamespace +from typing import Final + +import pytest + +from . import native_build + + +def test_needs_rebuild_when_bridge_is_missing() -> None: + assert native_build.needs_rebuild(None, 1.0) + + +def test_needs_rebuild_when_sources_are_newer_than_bridge() -> None: + assert native_build.needs_rebuild(1.0, 2.0) + + +def test_fresh_bridge_with_older_sources_needs_no_rebuild() -> None: + assert not native_build.needs_rebuild(2.0, 1.0) + + +def test_bridge_without_rust_sources_needs_no_rebuild() -> None: + assert not native_build.needs_rebuild(2.0, None) + + +def test_newest_source_mtime_tracks_rust_sources_and_skips_target(tmp_path: Final) -> None: + source: Final = tmp_path / "litellm-rust" / "crates" / "bridge" / "src" + source.mkdir(parents=True) + (source / "lib.rs").write_text("fn main() {}\n") + os.utime(source / "lib.rs", (1_000, 1_000)) + manifest: Final = tmp_path / "litellm-rust" / "crates" / "bridge" / "Cargo.toml" + manifest.write_text("[package]\n") + os.utime(manifest, (2_000, 2_000)) + lockfile: Final = tmp_path / "litellm-rust" / "Cargo.lock" + lockfile.write_text("") + os.utime(lockfile, (1_500, 1_500)) + target: Final = tmp_path / "litellm-rust" / "target" / "debug" / "junk.rs" + target.parent.mkdir(parents=True) + target.write_text("fn main() {}\n") + os.utime(target, (9_999, 9_999)) + + assert native_build._newest_source_mtime(tmp_path) == 2_000.0 + + +def test_newest_source_mtime_is_none_without_rust_workspace(tmp_path: Final) -> None: + assert native_build._newest_source_mtime(tmp_path) is None + + +def test_ensure_trace_bridge_rebuilds_when_stale( + tmp_path: Final, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + native: Final = tmp_path / "_native.abi3.so" + native.write_bytes(b"") + os.utime(native, (1_000, 1_000)) + source: Final = tmp_path / "litellm-rust" / "crates" / "bridge" / "src" / "lib.rs" + source.parent.mkdir(parents=True) + source.write_text("fn main() {}\n") + os.utime(source, (2_000, 2_000)) + state: Final = SimpleNamespace(rebuilt=False) + + def fake_rebuild(repo_root: object) -> tuple[bool, str]: + state.rebuilt = True + return True, "" + + monkeypatch.setattr(native_build, "_native_module_path", lambda: native) + monkeypatch.setattr(native_build, "_rebuild", fake_rebuild) + monkeypatch.setattr(native_build, "_drop_imported_bridge", lambda: None) + monkeypatch.setattr(native_build, "get_native_bridge", lambda: SimpleNamespace(_trace=object())) + + assert native_build.ensure_trace_bridge(tmp_path) is None + assert state.rebuilt is True + assert "Rebuilding native Rust bridge" in capsys.readouterr().out + + +def test_ensure_trace_bridge_reports_failed_rebuild(tmp_path: Final, monkeypatch: pytest.MonkeyPatch) -> None: + source: Final = tmp_path / "litellm-rust" / "crates" / "bridge" / "src" / "lib.rs" + source.parent.mkdir(parents=True) + source.write_text("fn main() {}\n") + + monkeypatch.setattr(native_build, "_native_module_path", lambda: None) + monkeypatch.setattr(native_build, "_rebuild", lambda repo_root: (False, "boom")) + + message: Final = native_build.ensure_trace_bridge(tmp_path) + + assert message is not None + assert "rebuild failed" in message + assert "boom" in message + + +def test_ensure_trace_bridge_flags_missing_trace_feature_without_rebuild( + tmp_path: Final, monkeypatch: pytest.MonkeyPatch +) -> None: + native: Final = tmp_path / "_native.abi3.so" + native.write_bytes(b"") + os.utime(native, (9_999, 9_999)) + source: Final = tmp_path / "litellm-rust" / "crates" / "bridge" / "src" / "lib.rs" + source.parent.mkdir(parents=True) + source.write_text("fn main() {}\n") + os.utime(source, (1_000, 1_000)) + state: Final = SimpleNamespace(rebuilt=False) + + def fake_rebuild(repo_root: object) -> tuple[bool, str]: + state.rebuilt = True + return True, "" + + monkeypatch.setattr(native_build, "_native_module_path", lambda: native) + monkeypatch.setattr(native_build, "_rebuild", fake_rebuild) + monkeypatch.setattr(native_build, "get_native_bridge", lambda: SimpleNamespace(_trace=None)) + + message: Final = native_build.ensure_trace_bridge(tmp_path) + + assert message is not None + assert "_trace" in message + assert state.rebuilt is False diff --git a/tests/rust-python-harness/shared/tracing/compare.py b/tests/rust-python-harness/shared/tracing/compare.py deleted file mode 100644 index 9c43bea6c0e..00000000000 --- a/tests/rust-python-harness/shared/tracing/compare.py +++ /dev/null @@ -1,57 +0,0 @@ -from __future__ import annotations - -from collections import Counter -from collections.abc import Mapping, Sequence -from dataclasses import dataclass -from typing import Final - - -@dataclass(frozen=True, slots=True) -class Operation: - name: str - started: int - finished: int - - -def compare_traces( - python: Sequence[Operation], - rust: Sequence[Operation], - mapping: Mapping[str, str], - required_order: Sequence[tuple[str, str]] = (), -) -> tuple[str, ...]: - python_names: Final = {operation.name for operation in python} - rust_names: Final = {operation.name for operation in rust} - problems: Final = ( - *(f"unmapped Python operation: {name}" for name in sorted(python_names - mapping.keys())), - *(f"unmapped Rust operation: {name}" for name in sorted(rust_names - set(mapping.values()))), - *(f"ambiguous Rust operation: {name}" for name, count in Counter(mapping.values()).items() if count > 1), - *( - f"invalid interval: {operation.name}" - for operation in (*python, *rust) - if operation.started > operation.finished - ), - ) - if problems: - return problems - python_counts: Final = Counter(operation.name for operation in python) - rust_counts: Final = Counter(operation.name for operation in rust) - counts: Final = tuple( - f"call count differs for {name}: Python={python_counts[name]}, Rust={rust_counts[target]}" - for name, target in mapping.items() - if python_counts[name] != rust_counts[target] - ) - ordering: Final = tuple( - f"{label}: required order {before} before {after} was not observed" - for before, after in required_order - for label, operations, first, second in ( - ("Python", python, before, after), - ("Rust", rust, mapping.get(before), mapping.get(after)), - ) - if not first - or not second - or not any(operation.name == first for operation in operations) - or not any(operation.name == second for operation in operations) - or max(operation.finished for operation in operations if operation.name == first) - > min(operation.started for operation in operations if operation.name == second) - ) - return (*counts, *ordering) diff --git a/tests/rust-python-harness/shared/tracing/native.py b/tests/rust-python-harness/shared/tracing/native.py new file mode 100644 index 00000000000..4f988f65294 --- /dev/null +++ b/tests/rust-python-harness/shared/tracing/native.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from typing import Final + +from pydantic import BaseModel, ConfigDict + +from .profiler import FunctionTraceEvent + + +class _TraceEventPayload(BaseModel): + model_config = ConfigDict(strict=True, extra="forbid") + id: int + parent_id: int | None + function: str + module_path: str | None = None + file: str | None = None + line: int | None = None + + +class TraceResponsePayload(BaseModel): + model_config = ConfigDict(strict=True, extra="forbid") + response: object + trace: tuple[_TraceEventPayload, ...] | list[_TraceEventPayload] + + +def native_trace_events(payload: object) -> tuple[FunctionTraceEvent, ...]: + response: Final = TraceResponsePayload.model_validate(payload) + return tuple( + FunctionTraceEvent( + event.id, + event.parent_id, + event.function, + event.module_path, + event.file, + event.line, + ) + for event in response.trace + ) diff --git a/tests/rust-python-harness/shared/tracing/profiler.py b/tests/rust-python-harness/shared/tracing/profiler.py new file mode 100644 index 00000000000..55d9818f507 --- /dev/null +++ b/tests/rust-python-harness/shared/tracing/profiler.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +import sys +import threading +from collections.abc import Generator +from contextlib import contextmanager +from dataclasses import dataclass +from pathlib import Path +from types import CodeType, FrameType +from typing import Final + + +@dataclass(frozen=True, slots=True) +class FunctionTraceEvent: + id: int + parent_id: int | None + function: str + module_path: str | None = None + file: str | None = None + line: int | None = None + + @property + def raw(self) -> str: + location: Final = f"{self.file}:{self.line}" if self.file is not None and self.line is not None else "" + qualified: Final = f"{self.module_path}::{self.function}" if self.module_path is not None else self.function + return f"{location} {qualified}" if location else qualified + + +class PythonProfiler: + def __init__(self, source_root: Path) -> None: + self._source_root: Final = str(source_root.resolve()) + "/" + self._seen_frames: Final[set[FrameType]] = set() + self._event_ids: Final[dict[FrameType, int]] = {} + self.events: Final[list[FunctionTraceEvent]] = [] + + def __call__(self, frame: FrameType, event: str, _arg: object) -> None: + if event != "call" or frame in self._seen_frames: + return + function_name: Final = self.function_name(frame.f_code) + if function_name is None: + return + event_id: Final = len(self.events) + parent_id: Final = next( + (self._event_ids[ancestor] for ancestor in _frame_ancestors(frame) if ancestor in self._event_ids), + None, + ) + self._seen_frames.add(frame) + self._event_ids[frame] = event_id + self.events.append(FunctionTraceEvent(id=event_id, parent_id=parent_id, function=function_name)) + + def function_name(self, code: CodeType) -> str | None: + if not code.co_filename.startswith(self._source_root): + return None + relative: Final = code.co_filename.removeprefix(self._source_root) + return f"{relative}:{code.co_firstlineno} {getattr(code, 'co_qualname', code.co_name)}" + + +class PythonFunctionUsageProfiler: + def __init__(self, source_root: Path, functions: frozenset[str]) -> None: + self._source_root: Final = str(source_root.resolve()) + "/" + self._functions: Final = functions + self.called: Final[set[str]] = set() + + def __call__(self, frame: FrameType, event: str, _arg: object) -> None: + if event != "call": + return + code: Final = frame.f_code + if not code.co_filename.startswith(self._source_root): + return + relative: Final = code.co_filename.removeprefix(self._source_root) + function: Final = f"{relative}:{code.co_firstlineno} {getattr(code, 'co_qualname', code.co_name)}" + if function in self._functions: + self.called.add(function) + + +def _frame_ancestors(frame: FrameType) -> Generator[FrameType]: + ancestor: Final = frame.f_back + if ancestor is not None: + yield ancestor + yield from _frame_ancestors(ancestor) + + +@contextmanager +def profile_python(source_root: Path, *, threads: bool = False) -> Generator[PythonProfiler]: + profiler: Final = PythonProfiler(source_root) + previous_thread: Final = threading.getprofile() + if threads: + threading.setprofile(profiler) + previous: Final = sys.getprofile() + sys.setprofile(profiler) + try: + yield profiler + finally: + sys.setprofile(previous) + if threads: + threading.setprofile(previous_thread) + + +@contextmanager +def profile_python_function_usage( + source_root: Path, + functions: frozenset[str], + *, + threads: bool = False, +) -> Generator[PythonFunctionUsageProfiler]: + profiler: Final = PythonFunctionUsageProfiler(source_root, functions) + previous_thread: Final = threading.getprofile() + if threads: + threading.setprofile(profiler) + previous: Final = sys.getprofile() + sys.setprofile(profiler) + try: + yield profiler + finally: + sys.setprofile(previous) + if threads: + threading.setprofile(previous_thread) diff --git a/tests/rust-python-harness/shared/tracing/pytest_usage.py b/tests/rust-python-harness/shared/tracing/pytest_usage.py new file mode 100644 index 00000000000..58af174df38 --- /dev/null +++ b/tests/rust-python-harness/shared/tracing/pytest_usage.py @@ -0,0 +1,335 @@ +from __future__ import annotations + +import argparse +import ast +import importlib +import inspect +import os +import subprocess +import sys +import tempfile +import warnings +from collections.abc import Generator, Sequence +from pathlib import Path +from typing import TYPE_CHECKING, Final + +from pluggy import HookimplMarker +from pydantic import BaseModel, ConfigDict + +from .profiler import profile_python_function_usage + +if TYPE_CHECKING: + import pytest + +hookimpl: Final = HookimplMarker("pytest") + + +class PythonFunctionIdentity(BaseModel): + model_config = ConfigDict(frozen=True, extra="forbid") + + file: str + line: int + qualname: str + + @property + def raw(self) -> str: + return f"{self.file}:{self.line} {self.qualname}" + + @property + def key(self) -> str: + return f"{self.file}::{self.qualname}" + + @classmethod + def from_trace(cls, raw: str) -> PythonFunctionIdentity: + location, separator, qualname = raw.partition(" ") + file, line_separator, line = location.rpartition(":") + if not separator or not line_separator or not file or not line.isdigit() or not qualname: + raise ValueError(f"Unrecognized Python trace function: {raw}") + return cls(file=file, line=int(line), qualname=qualname) + + +class PythonFunctionReference(BaseModel): + model_config = ConfigDict(frozen=True, extra="forbid") + + module: str + qualname: str + + @property + def owner(self) -> str: + return self.qualname.partition(".")[0] + + def resolve(self, source_root: Path) -> PythonFunctionIdentity: + value: object = importlib.import_module(self.module) + for component in self.qualname.split("."): + value = getattr(value, component) + function: Final = inspect.unwrap(value) + code: Final = getattr(function, "__code__", None) + if code is None: + raise ValueError(f"Python function has no code object: {self.module}:{self.qualname}") + source: Final = Path(code.co_filename).resolve() + try: + relative: Final = source.relative_to(source_root.resolve()) + except ValueError as error: + raise ValueError(f"Python function is outside {source_root}: {source}") from error + return PythonFunctionIdentity( + file=relative.as_posix(), + line=code.co_firstlineno, + qualname=code.co_qualname, + ) + + +class RustFunctionIdentity(BaseModel): + model_config = ConfigDict(frozen=True, extra="forbid") + + file: str + line: int + module_path: str + function: str + + @property + def test_module(self) -> str: + _, separator, module = self.module_path.partition("::") + if not separator: + raise ValueError(f"Rust function has no crate-qualified module: {self.module_path}") + return f"{module}::tests" + + @classmethod + def from_trace(cls, raw: str) -> RustFunctionIdentity: + location, separator, qualified = raw.partition(" ") + file, line_separator, line = location.rpartition(":") + module_path, function_separator, function = qualified.rpartition("::") + if ( + not separator + or not line_separator + or not function_separator + or not file + or not line.isdigit() + or not module_path + or not function + ): + raise ValueError(f"Unrecognized Rust trace function: {raw}") + return cls(file=file, line=int(line), module_path=module_path, function=function) + + +class PythonFunctionUsage(BaseModel): + model_config = ConfigDict(frozen=True, extra="forbid") + + function: PythonFunctionIdentity + tests: tuple[str, ...] + + +class PythonUsageReport(BaseModel): + model_config = ConfigDict(frozen=True, extra="forbid") + + usages: tuple[PythonFunctionUsage, ...] + collected_tests: tuple[str, ...] + exit_code: int + problems: tuple[str, ...] = () + + +def candidate_test_files( + functions: Sequence[PythonFunctionReference | PythonFunctionIdentity], + search_roots: Sequence[str], + repo_root: Path, + *, + exclude_roots: Sequence[str] = (), +) -> tuple[str, ...]: + owners: Final = frozenset( + function.owner if isinstance(function, PythonFunctionReference) else function.qualname.partition(".")[0] + for function in functions + if "." in function.qualname + and ( + isinstance(function, PythonFunctionReference) + or function.file.startswith("ocr/") + or "/ocr/" in function.file + ) + ) + top_level_functions: Final = frozenset( + function.qualname for function in functions if "." not in function.qualname and function.qualname.isidentifier() + ) + candidates: Final = tuple( + path.relative_to(repo_root).as_posix() + for root in search_roots + for path in sorted((repo_root / root).rglob("test*.py")) + if not any( + path == repo_root / excluded or path.is_relative_to(repo_root / excluded) for excluded in exclude_roots + ) + if _references_function(path, owners, top_level_functions) + ) + return tuple(dict.fromkeys(candidates)) + + +def _references_function(path: Path, owners: frozenset[str], top_level_functions: frozenset[str]) -> bool: + contents: Final = path.read_text(errors="ignore") + if any(owner in contents for owner in owners): + return True + try: + with warnings.catch_warnings(): + warnings.simplefilter("ignore", SyntaxWarning) + tree: Final = ast.parse(contents) + except SyntaxError: + return False + aliases: Final = frozenset( + alias.asname or alias.name + for node in ast.walk(tree) + if isinstance(node, ast.ImportFrom) + for alias in node.names + if alias.name in top_level_functions + ) + names: Final = top_level_functions | aliases + return any( + isinstance(node, ast.Call) + and ( + (isinstance(node.func, ast.Name) and node.func.id in names) + or (isinstance(node.func, ast.Attribute) and node.func.attr in top_level_functions) + ) + for node in ast.walk(tree) + ) + + +class _WorkerConfig(BaseModel): + model_config = ConfigDict(frozen=True, extra="forbid") + + functions: tuple[PythonFunctionIdentity, ...] + source_root: Path + output: Path + pytest_args: tuple[str, ...] + + +class _FunctionUsagePlugin: + def __init__(self, functions: tuple[PythonFunctionIdentity, ...], source_root: Path) -> None: + self._functions: Final = functions + self._function_names: Final = frozenset(function.raw for function in functions) + self._source_root: Final = source_root + self._tests_by_function: Final[dict[str, set[str]]] = {function.raw: set() for function in functions} + self.collected_tests: tuple[str, ...] = () + self.problems: tuple[str, ...] = () + + def pytest_collection_finish(self, session: pytest.Session) -> None: + self.collected_tests = tuple(item.nodeid for item in session.items) + + def pytest_collectreport(self, report: pytest.CollectReport) -> None: + if report.failed: + self.problems = (*self.problems, str(report.longrepr)) + + @hookimpl(hookwrapper=True) + def pytest_runtest_protocol(self, item: pytest.Item, nextitem: pytest.Item | None) -> Generator[None, object, None]: + del nextitem + with profile_python_function_usage(self._source_root, self._function_names, threads=True) as profiler: + yield + for function in self._functions: + if function.raw in profiler.called: + self._tests_by_function[function.raw].add(item.nodeid) + + def usages(self) -> tuple[PythonFunctionUsage, ...]: + return tuple( + PythonFunctionUsage( + function=function, + tests=tuple(sorted(self._tests_by_function[function.raw])), + ) + for function in self._functions + ) + + +def collect_python_function_tests( + functions: Sequence[PythonFunctionIdentity], + selectors: Sequence[str], + repo_root: Path, + *, + source_root: Path | None = None, + exclusions: Sequence[str] = (), +) -> PythonUsageReport: + selected_functions: Final = tuple(dict.fromkeys(functions)) + if not selected_functions: + raise ValueError("Python function discovery needs at least one function") + if not selectors: + raise ValueError("Python function discovery needs at least one test selector") + with tempfile.TemporaryDirectory(prefix="litellm-function-tests-") as directory: + temporary: Final = Path(directory) + config_path: Final = temporary / "config.json" + output_path: Final = temporary / "report.json" + config: Final = _WorkerConfig( + functions=selected_functions, + source_root=source_root or repo_root / "litellm", + output=output_path, + pytest_args=tuple( + ( + "-o", + "consider_namespace_packages=true", + "-p", + "no:cacheprovider", + *selectors, + *(f"--deselect={nodeid}" for nodeid in exclusions), + ) + ), + ) + config_path.write_text(config.model_dump_json()) + import_roots: Final = tuple( + dict.fromkeys( + ( + str(repo_root), + str(source_root or repo_root / "litellm"), + *( + str(path.parent if path.suffix == ".py" else path) + for selector in selectors + if (path := repo_root / selector.partition("::")[0]).exists() + ), + os.environ.get("PYTHONPATH", ""), + ) + ) + ) + env: Final = { + **os.environ, + "PYTHONPATH": os.pathsep.join(import_roots), + } + try: + result: Final = subprocess.run( + (sys.executable, "-m", __name__, str(config_path)), + cwd=repo_root, + env=env, + capture_output=True, + text=True, + timeout=600, + check=False, + ) + except (OSError, subprocess.TimeoutExpired) as error: + return PythonUsageReport(usages=(), collected_tests=(), exit_code=1, problems=(str(error),)) + if not output_path.exists(): + return PythonUsageReport( + usages=(), + collected_tests=(), + exit_code=result.returncode or 1, + problems=((result.stdout + result.stderr).strip(),), + ) + report: Final = PythonUsageReport.model_validate_json(output_path.read_text()) + process_output: Final = (result.stdout + result.stderr).strip() + if result.returncode and not report.problems and process_output: + return report.model_copy(update={"problems": (process_output,)}) + return report + + +def _run_worker(config: _WorkerConfig) -> int: + import pytest + + plugin: Final = _FunctionUsagePlugin(config.functions, config.source_root) + exit_code: Final = int(pytest.main(list(config.pytest_args), plugins=[plugin])) + report: Final = PythonUsageReport( + usages=plugin.usages(), + collected_tests=plugin.collected_tests, + exit_code=exit_code, + problems=plugin.problems, + ) + config.output.write_text(report.model_dump_json()) + return exit_code + + +def main(argv: Sequence[str] | None = None) -> int: + parser: Final = argparse.ArgumentParser() + parser.add_argument("config", type=Path) + namespace: Final = parser.parse_args(argv) + config: Final = _WorkerConfig.model_validate_json(namespace.config.read_text()) + return _run_worker(config) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/rust-python-harness/shared/tracing/steps.py b/tests/rust-python-harness/shared/tracing/steps.py new file mode 100644 index 00000000000..2475f0fdcc5 --- /dev/null +++ b/tests/rust-python-harness/shared/tracing/steps.py @@ -0,0 +1,271 @@ +from __future__ import annotations + +import re +from collections import Counter +from collections.abc import Sequence +from dataclasses import dataclass +from typing import Final, Literal + +from .profiler import FunctionTraceEvent + +Engine = Literal["python", "rust"] + + +@dataclass(frozen=True, slots=True) +class TraceMapping: + span: str + python: re.Pattern[str] | None + rust: str | None + + +def mapping( + *, + python_frame: str | None = None, + rust_span: str | None = None, + span: str | None = None, +) -> TraceMapping: + if rust_span is None: + if python_frame is None: + raise ValueError("mapping needs a python_frame pattern, a rust_span name, or both") + if span is None: + raise ValueError("a python-only mapping needs an explicit span to compare under") + return TraceMapping(span, re.compile(python_frame), None) + if python_frame is None: + return TraceMapping(rust_span, None, rust_span) + if span is not None and span != rust_span: + raise ValueError(f"span {span!r} disagrees with rust_span {rust_span!r}") + return TraceMapping(rust_span, re.compile(python_frame), rust_span) + + +@dataclass(frozen=True, slots=True) +class TraceContract: + unordered_children_of: frozenset[str] = frozenset() + + +@dataclass(frozen=True, slots=True) +class PipelineStep: + id: int + parent_id: int | None + span: str + raw: str + + +@dataclass(frozen=True, slots=True) +class PipelineProjection: + steps: tuple[PipelineStep, ...] = () + unmatched: int = 0 + + +def _span_for(engine: Engine, function: str, mappings: Sequence[TraceMapping]) -> str | None: + matches: Final = tuple( + item.span + for item in mappings + if ( + engine == "python" + and item.python is not None + and item.python.search(function) + or engine == "rust" + and item.rust == function + ) + ) + if len(matches) > 1: + raise ValueError(f"{engine} event {function!r} matches multiple trace mappings: {matches}") + if matches: + return matches[0] + return function if engine == "rust" else None + + +def pipeline_projection( + engine: Engine, events: Sequence[FunctionTraceEvent], mappings: Sequence[TraceMapping] +) -> PipelineProjection: + raw_parents: dict[int, int | None] = {} + projected_ids: set[int] = set() + shown: list[PipelineStep] = [] + unmatched: int = 0 + for event in events: + if event.id in raw_parents: + raise ValueError(f"duplicate trace event id {event.id}") + if event.parent_id is not None and event.parent_id not in raw_parents: + raise ValueError(f"trace event {event.id} references unknown or later parent {event.parent_id}") + raw_parents[event.id] = event.parent_id + span = _span_for(engine, event.function, mappings) + if span is None: + unmatched += 1 + continue + parent_id: int | None = event.parent_id + while parent_id is not None and parent_id not in projected_ids: + parent_id = raw_parents[parent_id] + shown.append(PipelineStep(event.id, parent_id, span, event.raw)) + projected_ids.add(event.id) + return PipelineProjection(tuple(shown), unmatched) + + +@dataclass(frozen=True, slots=True) +class TraceNode: + id: int + span: str + children: tuple[TraceNode, ...] + + +def trace_depths(steps: Sequence[PipelineStep]) -> dict[int, int]: + depths: dict[int, int] = {} + for step in steps: + depths[step.id] = 0 if step.parent_id is None else depths[step.parent_id] + 1 + return depths + + +def _forest(steps: Sequence[PipelineStep]) -> tuple[TraceNode, ...]: + children: dict[int | None, list[PipelineStep]] = {} + known: set[int] = set() + for step in steps: + if step.id in known: + raise ValueError(f"duplicate projected event id {step.id}") + if step.parent_id is not None and step.parent_id not in known: + raise ValueError(f"projected event {step.id} references unknown or later parent {step.parent_id}") + known.add(step.id) + children.setdefault(step.parent_id, []).append(step) + + def node(step: PipelineStep) -> TraceNode: + return TraceNode(step.id, step.span, tuple(node(child) for child in children.get(step.id, ()))) + + return tuple(node(step) for step in children.get(None, ())) + + +def _exclusive_spans(engine: Engine, mappings: Sequence[TraceMapping]) -> frozenset[str]: + return frozenset( + item.span + for item in mappings + if (engine == "python" and item.rust is None) or (engine == "rust" and item.python is None) + ) + + +def _comparable_steps( + engine: Engine, steps: Sequence[PipelineStep], mappings: Sequence[TraceMapping] +) -> tuple[PipelineStep, ...]: + exclusive: Final = _exclusive_spans(engine, mappings) + raw_parents: Final = {step.id: step.parent_id for step in steps} + included: Final = {step.id for step in steps if step.span not in exclusive} + comparable: list[PipelineStep] = [] + for step in steps: + if step.id not in included: + continue + parent_id: int | None = step.parent_id + while parent_id is not None and parent_id not in included: + parent_id = raw_parents[parent_id] + comparable.append(PipelineStep(step.id, parent_id, step.span, step.raw)) + return tuple(comparable) + + +def _signature(node: TraceNode, contract: TraceContract) -> tuple[object, ...]: + children: tuple[tuple[object, ...], ...] = tuple(_signature(child, contract) for child in node.children) + normalized: Final = tuple(sorted(children, key=repr)) if node.span in contract.unordered_children_of else children + return (node.span, normalized) + + +def trace_signature( + engine: Engine, + steps: Sequence[PipelineStep], + mappings: Sequence[TraceMapping], + contract: TraceContract, +) -> tuple[tuple[object, ...], ...]: + return tuple(_signature(root, contract) for root in _forest(_comparable_steps(engine, steps, mappings))) + + +@dataclass(frozen=True, slots=True) +class TraceDiff: + python_only: tuple[str, ...] + rust_only: tuple[str, ...] + shared_order_matches: bool + missing_mappings: tuple[str, ...] = () + first_difference: str | None = None + + @property + def matches(self) -> bool: + return ( + not self.python_only + and not self.rust_only + and not self.missing_mappings + and self.shared_order_matches + ) + + +def _missing_mappings( + python: Sequence[PipelineStep], rust: Sequence[PipelineStep], mappings: Sequence[TraceMapping] +) -> tuple[str, ...]: + python_seen: Final = frozenset(step.span for step in python) + rust_seen: Final = frozenset(step.span for step in rust) + return tuple( + item.span + for item in mappings + if (item.python is not None and item.span not in python_seen) + or (item.rust is not None and item.span not in rust_seen) + ) + + +def _first_difference( + python: Sequence[PipelineStep], + rust: Sequence[PipelineStep], + mappings: Sequence[TraceMapping], + contract: TraceContract, +) -> str | None: + python_forest: Final = _forest(_comparable_steps("python", python, mappings)) + rust_forest: Final = _forest(_comparable_steps("rust", rust, mappings)) + + def compare_children( + python_nodes: Sequence[TraceNode], rust_nodes: Sequence[TraceNode], path: str, *, unordered: bool + ) -> str | None: + if unordered: + python_signatures: Final = Counter(_signature(node, contract) for node in python_nodes) + rust_signatures: Final = Counter(_signature(node, contract) for node in rust_nodes) + if python_signatures != rust_signatures: + return f"{path}: unordered child subtree multiset differs" + return None + for index in range(max(len(python_nodes), len(rust_nodes))): + child_path = f"{path}/child[{index + 1}]" + if index >= len(python_nodes): + return f"{child_path}: Rust has extra {rust_nodes[index].span!r}" + if index >= len(rust_nodes): + return f"{child_path}: Python has extra {python_nodes[index].span!r}" + python_node = python_nodes[index] + rust_node = rust_nodes[index] + if python_node.span != rust_node.span: + return f"{child_path}: Python={python_node.span!r}, Rust={rust_node.span!r}" + difference = compare_children( + python_node.children, + rust_node.children, + f"{child_path}/{python_node.span}", + unordered=python_node.span in contract.unordered_children_of, + ) + if difference is not None: + return difference + return None + + return compare_children(python_forest, rust_forest, "root", unordered=False) + + +def trace_diff( + python: Sequence[PipelineStep], + rust: Sequence[PipelineStep], + mappings: Sequence[TraceMapping] = (), + contract: TraceContract = TraceContract(), +) -> TraceDiff: + python_comparable: Final = _comparable_steps("python", python, mappings) + rust_comparable: Final = _comparable_steps("rust", rust, mappings) + python_spans: Final = tuple(step.span for step in python_comparable) + rust_spans: Final = tuple(step.span for step in rust_comparable) + python_counts: Final = Counter(python_spans) + rust_counts: Final = Counter(rust_spans) + python_only_counts: Final = python_counts - rust_counts + rust_only_counts: Final = rust_counts - python_counts + python_only: Final = tuple( + span for span, count in python_only_counts.items() for _ in range(count) + ) + rust_only: Final = tuple(span for span, count in rust_only_counts.items() for _ in range(count)) + first_difference: Final = _first_difference(python, rust, mappings, contract) + return TraceDiff( + python_only=python_only, + rust_only=rust_only, + shared_order_matches=bool(python_comparable or rust_comparable) and first_difference is None, + missing_mappings=_missing_mappings(python, rust, mappings), + first_difference=first_difference, + ) diff --git a/tests/rust-python-harness/shared/tracing/test_compare.py b/tests/rust-python-harness/shared/tracing/test_compare.py deleted file mode 100644 index 2dfad24846b..00000000000 --- a/tests/rust-python-harness/shared/tracing/test_compare.py +++ /dev/null @@ -1,33 +0,0 @@ -from __future__ import annotations - -import pytest - -from .compare import Operation, compare_traces - - -@pytest.mark.parametrize( - ("rust", "message"), - ( - ((Operation("decode", 0, 1), Operation("send", 2, 3)), None), - ((Operation("decode", 0, 1), Operation("send", 2, 3), Operation("send", 4, 5)), "call count differs"), - ((Operation("send", 0, 1), Operation("decode", 2, 3)), "required order"), - ((Operation("decode", 0, 4), Operation("send", 2, 3)), "required order"), - ((Operation("decode", 0, 1), Operation("unknown", 2, 3)), "unmapped Rust"), - ), -) -def test_compare_mapped_calls_and_required_completion_order(rust: tuple[Operation, ...], message: str | None) -> None: - problems = compare_traces( - (Operation("parse", 0, 1), Operation("request", 2, 3)), - rust, - {"parse": "decode", "request": "send"}, - (("parse", "request"),), - ) - if message is None: - assert problems == () - else: - assert any(message in problem for problem in problems) - - -def test_missing_required_operations_and_ambiguous_mappings_fail() -> None: - assert compare_traces((), (), {"parse": "decode"}, (("parse", "request"),)) - assert compare_traces((), (), {"parse": "decode", "request": "decode"}) == ("ambiguous Rust operation: decode",) diff --git a/tests/rust-python-harness/shared/tracing/test_profiler.py b/tests/rust-python-harness/shared/tracing/test_profiler.py new file mode 100644 index 00000000000..ba85ffe63cd --- /dev/null +++ b/tests/rust-python-harness/shared/tracing/test_profiler.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +import asyncio +import sys +import threading +from pathlib import Path +from typing import Final + +import pytest + +from .profiler import FunctionTraceEvent, PythonProfiler, profile_python, profile_python_function_usage + + +def _events_named(profiler: PythonProfiler, name: str) -> tuple[FunctionTraceEvent, ...]: + return tuple(event for event in profiler.events if event.function.endswith(name)) + + +def test_profiler_keeps_repeated_calls() -> None: + def called() -> None: + return None + + with profile_python(Path(__file__).parent) as profiler: + called() + called() + + assert len(_events_named(profiler, "called")) == 2 + + +def test_profiler_records_real_frame_ancestry() -> None: + def called() -> None: + return None + + def outer() -> None: + called() + + with profile_python(Path(__file__).parent) as profiler: + outer() + + outer_event, called_event = (event for event in profiler.events if event.function.endswith(("outer", "called"))) + assert called_event.parent_id == outer_event.id + + +def test_profiler_restores_previous_profiler_after_failure() -> None: + previous: Final = sys.getprofile() + + with pytest.raises(RuntimeError, match="stop"): + with profile_python(Path(__file__).parent): + raise RuntimeError("stop") + + assert sys.getprofile() is previous + + +def test_profiler_does_not_count_coroutine_resumption_as_another_call() -> None: + async def suspended() -> None: + await asyncio.sleep(0) + await asyncio.sleep(0) + + with profile_python(Path(__file__).parent) as profiler: + asyncio.run(suspended()) + + assert len(_events_named(profiler, "suspended")) == 1 + + +def test_profiler_preserves_parent_across_coroutine_suspension() -> None: + def called() -> None: + return None + + async def suspended() -> None: + await asyncio.sleep(0) + called() + + with profile_python(Path(__file__).parent) as profiler: + asyncio.run(suspended()) + + suspended_event: Final = _events_named(profiler, "suspended")[0] + called_event: Final = _events_named(profiler, "called")[0] + assert called_event.parent_id == suspended_event.id + + +def test_profiler_captures_worker_threads_when_enabled() -> None: + def called() -> None: + return None + + with profile_python(Path(__file__).parent, threads=True) as profiler: + thread: Final = threading.Thread(target=called) + thread.start() + thread.join() + + called_event: Final = _events_named(profiler, "called")[0] + assert called_event.parent_id is None + + +def test_function_usage_profiler_records_only_selected_functions() -> None: + def selected() -> None: + return None + + def ignored() -> None: + return None + + source_root: Final = Path(__file__).parent + function: Final = PythonProfiler(source_root).function_name(selected.__code__) + assert function is not None + + with profile_python_function_usage(source_root, frozenset((function,))) as profiler: + selected() + ignored() + + assert profiler.called == {function} diff --git a/tests/rust-python-harness/shared/tracing/test_pytest_usage.py b/tests/rust-python-harness/shared/tracing/test_pytest_usage.py new file mode 100644 index 00000000000..9f40da19010 --- /dev/null +++ b/tests/rust-python-harness/shared/tracing/test_pytest_usage.py @@ -0,0 +1,168 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Final + +import pytest + +from .pytest_usage import ( + PythonFunctionIdentity, + PythonFunctionReference, + RustFunctionIdentity, + candidate_test_files, + collect_python_function_tests, +) + + +def test_collects_parameterized_tests_that_execute_function(tmp_path: Path) -> None: + (tmp_path / "pytest.ini").write_text("[pytest]\n") + (tmp_path / "source.py").write_text("def target():\n return 1\n\ndef other():\n return 2\n") + (tmp_path / "test_source.py").write_text( + "import pytest\n" + "from source import other, target\n" + "@pytest.mark.parametrize('value', [1, 2])\n" + "def test_target(value): assert target() + value > 0\n" + "def test_other(): assert other() == 2\n" + ) + target: Final = PythonFunctionIdentity(file="source.py", line=1, qualname="target") + + report: Final = collect_python_function_tests( + (target,), + ("test_source.py",), + tmp_path, + source_root=tmp_path, + ) + + assert report.exit_code == 0, report.problems + assert report.usages[0].tests == ( + "test_source.py::test_target[1]", + "test_source.py::test_target[2]", + ) + + +def test_collects_async_and_threaded_function_calls(tmp_path: Path) -> None: + (tmp_path / "pytest.ini").write_text("[pytest]\n") + (tmp_path / "source.py").write_text( + "async def async_target():\n return 1\n\ndef threaded_target():\n return 2\n" + ) + (tmp_path / "test_source.py").write_text( + "import asyncio\n" + "from threading import Thread\n" + "from source import async_target, threaded_target\n" + "def test_async(): assert asyncio.run(async_target()) == 1\n" + "def test_thread():\n" + " thread = Thread(target=threaded_target)\n" + " thread.start()\n" + " thread.join()\n" + ) + functions: Final = ( + PythonFunctionIdentity(file="source.py", line=1, qualname="async_target"), + PythonFunctionIdentity(file="source.py", line=4, qualname="threaded_target"), + ) + + report: Final = collect_python_function_tests( + functions, + ("test_source.py",), + tmp_path, + source_root=tmp_path, + ) + + assert report.exit_code == 0, report.problems + assert report.usages[0].tests == ("test_source.py::test_async",) + assert report.usages[1].tests == ("test_source.py::test_thread",) + + +def test_adds_candidate_directory_to_worker_import_path(tmp_path: Path) -> None: + (tmp_path / "pytest.ini").write_text("[pytest]\n") + source: Final = tmp_path / "source" + tests: Final = tmp_path / "tests" + source.mkdir() + tests.mkdir() + (source / "implementation.py").write_text("def target():\n return 1\n") + (tests / "helper.py").write_text("VALUE = 1\n") + (tests / "test_source.py").write_text( + "from helper import VALUE\nfrom implementation import target\ndef test_target(): assert target() == VALUE\n" + ) + target: Final = PythonFunctionIdentity(file="implementation.py", line=1, qualname="target") + + report: Final = collect_python_function_tests( + (target,), + ("tests/test_source.py",), + tmp_path, + source_root=source, + ) + + assert report.exit_code == 0, report.problems + assert report.usages[0].tests == ("tests/test_source.py::test_target",) + + +def test_parses_function_identity_from_trace() -> None: + function: Final = PythonFunctionIdentity.from_trace("llms/mistral/ocr/transformation.py:72 Config.map") + + assert function.file == "llms/mistral/ocr/transformation.py" + assert function.line == 72 + assert function.qualname == "Config.map" + + +def test_resolves_function_and_finds_candidate_test_files(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + package: Final = tmp_path / "package" + tests: Final = tmp_path / "tests" + package.mkdir() + tests.mkdir() + (package / "__init__.py").write_text("") + (package / "implementation.py").write_text("class Config:\n def transform(self):\n return 1\n") + (tests / "test_implementation.py").write_text("from package.implementation import Config\n") + (tests / "test_unrelated.py").write_text("def test_other(): pass\n") + monkeypatch.syspath_prepend(tmp_path) + reference: Final = PythonFunctionReference(module="package.implementation", qualname="Config.transform") + + function: Final = reference.resolve(tmp_path) + candidates: Final = candidate_test_files((reference,), ("tests",), tmp_path) + + assert function.file == "package/implementation.py" + assert function.qualname == "Config.transform" + assert candidates == ("tests/test_implementation.py",) + + +def test_candidate_test_files_excludes_harness_roots(tmp_path: Path) -> None: + tests: Final = tmp_path / "tests" + harness: Final = tests / "harness" + harness.mkdir(parents=True) + (tests / "test_implementation.py").write_text("from package.implementation import Config\n") + (harness / "test_fixture.py").write_text("from package.implementation import Config\n") + function: Final = PythonFunctionReference(module="package.implementation", qualname="Config.transform") + + candidates: Final = candidate_test_files( + (function,), + ("tests",), + tmp_path, + exclude_roots=("tests/harness",), + ) + + assert candidates == ("tests/test_implementation.py",) + + +def test_candidate_test_files_finds_top_level_calls_and_import_aliases(tmp_path: Path) -> None: + tests: Final = tmp_path / "tests" + tests.mkdir() + (tests / "test_attribute.py").write_text("import package\ndef test_call(): package.ocr()\n") + (tests / "test_alias.py").write_text("from package import ocr as run_ocr\ndef test_call(): run_ocr()\n") + (tests / "test_unrelated.py").write_text("def test_call(): return 'ocr'\n") + function: Final = PythonFunctionIdentity(file="ocr/main.py", line=1, qualname="ocr") + + candidates: Final = candidate_test_files((function,), ("tests",), tmp_path) + + assert candidates == ( + "tests/test_alias.py", + "tests/test_attribute.py", + ) + + +def test_parses_rust_function_identity_and_derives_test_module() -> None: + function: Final = RustFunctionIdentity.from_trace( + "crates/core/src/providers/mistral/ocr/transformation.rs:73 " + "litellm_core::providers::mistral::ocr::transformation::supported_ocr_params" + ) + + assert function.file == "crates/core/src/providers/mistral/ocr/transformation.rs" + assert function.test_module == "providers::mistral::ocr::transformation::tests" diff --git a/tests/rust-python-harness/shared/tracing/test_steps.py b/tests/rust-python-harness/shared/tracing/test_steps.py new file mode 100644 index 00000000000..2efc6a3c579 --- /dev/null +++ b/tests/rust-python-harness/shared/tracing/test_steps.py @@ -0,0 +1,157 @@ +from __future__ import annotations + +from typing import Final + +import pytest + +from .profiler import FunctionTraceEvent +from .steps import Engine, TraceContract, mapping, pipeline_projection, trace_depths, trace_diff + +MAPPINGS: Final = ( + mapping(rust_span="route", python_frame=r"entry$"), + mapping(rust_span="provider", python_frame=r"provider$"), + mapping(rust_span="request", python_frame=r"request$"), + mapping(rust_span="http", python_frame=r"post$"), + mapping(rust_span="response", python_frame=r"response$"), +) + + +def event(event_id: int, function: str, parent_id: int | None = None) -> FunctionTraceEvent: + return FunctionTraceEvent(event_id, parent_id, function) + + +def test_python_projection_collapses_unmapped_parents_and_counts_noise() -> None: + events: Final = ( + event(0, "module.py:1 entry"), + event(1, "noise", 0), + event(2, "module.py:2 provider", 1), + event(3, "module.py:3 request", 0), + event(4, "client.py:4 post", 3), + event(5, "module.py:5 response", 0), + ) + projection: Final = pipeline_projection("python", events, MAPPINGS) + assert projection.unmatched == 1 + assert [(step.id, step.parent_id, step.span, step.raw) for step in projection.steps] == [ + (0, None, "route", "module.py:1 entry"), + (2, 0, "provider", "module.py:2 provider"), + (3, 0, "request", "module.py:3 request"), + (4, 3, "http", "client.py:4 post"), + (5, 0, "response", "module.py:5 response"), + ] + + +def test_rust_projection_keeps_unknown_spans() -> None: + projection: Final = pipeline_projection("rust", (event(0, "route"), event(1, "new_span", 0)), MAPPINGS) + assert [(step.span, step.parent_id) for step in projection.steps] == [("route", None), ("new_span", 0)] + + +def test_projection_preserves_repeated_occurrences() -> None: + projection: Final = pipeline_projection( + "rust", + (event(0, "route"), event(1, "http", 0), event(2, "http", 0)), + MAPPINGS, + ) + assert [step.span for step in projection.steps] == ["route", "http", "http"] + + +def test_projection_preserves_multiple_roots() -> None: + projection: Final = pipeline_projection("rust", (event(0, "route"), event(1, "request")), MAPPINGS) + assert trace_depths(projection.steps) == {0: 0, 1: 0} + + +def test_projection_rejects_duplicate_and_unknown_parent_ids() -> None: + with pytest.raises(ValueError, match="duplicate trace event id"): + pipeline_projection("rust", (event(0, "route"), event(0, "request")), MAPPINGS) + with pytest.raises(ValueError, match="unknown or later parent"): + pipeline_projection("rust", (event(1, "request", 0),), MAPPINGS) + + +@pytest.mark.parametrize("engine", ("python", "rust")) +def test_rust_only_mappings_do_not_swallow_python_frames(engine: Engine) -> None: + projection: Final = pipeline_projection( + engine, + (event(0, "anything"),), + (mapping(rust_span="rust_only_span"),), + ) + if engine == "python": + assert projection.unmatched == 1 + assert projection.steps == () + else: + assert projection.unmatched == 0 + assert projection.steps[0].span == "anything" + + +def test_mapping_builder_rejects_empty_and_ambiguous_declarations() -> None: + with pytest.raises(ValueError, match="mapping needs"): + mapping() + with pytest.raises(ValueError, match="python-only mapping needs"): + mapping(python_frame=r"frame$") + with pytest.raises(ValueError, match="disagrees with"): + mapping(rust_span="span_a", python_frame=r"frame$", span="span_b") + + +def test_projection_rejects_ambiguous_python_mapping() -> None: + mappings: Final = ( + mapping(rust_span="first", python_frame=r"same$"), + mapping(rust_span="second", python_frame=r"same$"), + ) + with pytest.raises(ValueError, match="multiple trace mappings"): + pipeline_projection("python", (event(0, "module.py:1 same"),), mappings) + + +def test_trace_diff_matches_identical_occurrence_trees() -> None: + mappings: Final = (MAPPINGS[0], MAPPINGS[2]) + steps: Final = pipeline_projection( + "rust", (event(0, "route"), event(1, "request", 0), event(2, "request", 0)), mappings + ).steps + assert trace_diff(steps, steps, mappings).matches + + +def test_trace_diff_rejects_missing_occurrence_and_parent_drift() -> None: + python: Final = pipeline_projection( + "rust", (event(0, "route"), event(1, "request", 0), event(2, "request", 0)), MAPPINGS + ).steps + missing: Final = pipeline_projection("rust", (event(0, "route"), event(1, "request", 0)), MAPPINGS).steps + reparented: Final = pipeline_projection( + "rust", (event(0, "route"), event(1, "request", 0), event(2, "request", 1)), MAPPINGS + ).steps + assert trace_diff(python, missing, MAPPINGS).python_only == ("request",) + assert not trace_diff(python, reparented, MAPPINGS).matches + + +def test_trace_diff_rejects_sequential_reorder() -> None: + first: Final = pipeline_projection( + "rust", (event(0, "route"), event(1, "request", 0), event(2, "response", 0)), MAPPINGS + ).steps + second: Final = pipeline_projection( + "rust", (event(0, "route"), event(1, "response", 0), event(2, "request", 0)), MAPPINGS + ).steps + diff: Final = trace_diff(first, second, MAPPINGS) + assert not diff.matches + assert diff.first_difference == "root/child[1]/route/child[1]: Python='request', Rust='response'" + + +def test_trace_diff_allows_reordered_concurrent_children() -> None: + mappings: Final = (MAPPINGS[0], MAPPINGS[2], MAPPINGS[4]) + first: Final = pipeline_projection( + "rust", (event(0, "route"), event(1, "request", 0), event(2, "response", 0)), mappings + ).steps + second: Final = pipeline_projection( + "rust", (event(0, "route"), event(1, "response", 0), event(2, "request", 0)), mappings + ).steps + contract: Final = TraceContract(frozenset({"route"})) + assert trace_diff(first, second, mappings, contract).matches + + +def test_trace_diff_prunes_declared_engine_only_nodes_but_requires_them() -> None: + mappings: Final = (MAPPINGS[0], mapping(rust_span="rust_prepare")) + python: Final = pipeline_projection("python", (event(0, "module.py:1 entry"),), mappings).steps + rust: Final = pipeline_projection( + "rust", (event(0, "route"), event(1, "rust_prepare", 0)), mappings + ).steps + assert trace_diff(python, rust, mappings).matches + assert trace_diff(python, rust[:1], mappings).missing_mappings == ("rust_prepare",) + + +def test_trace_diff_does_not_claim_empty_traces_match() -> None: + assert not trace_diff((), ()).matches diff --git a/tests/rust-python-harness/strategies/e2e_parity/gateway/__init__.py b/tests/rust-python-harness/shared/unit_runners/__init__.py similarity index 100% rename from tests/rust-python-harness/strategies/e2e_parity/gateway/__init__.py rename to tests/rust-python-harness/shared/unit_runners/__init__.py diff --git a/tests/rust-python-harness/strategies/unit_tests/python_runner.py b/tests/rust-python-harness/shared/unit_runners/python_runner.py similarity index 63% rename from tests/rust-python-harness/strategies/unit_tests/python_runner.py rename to tests/rust-python-harness/shared/unit_runners/python_runner.py index 900b30180dc..86ea0dbf1b9 100644 --- a/tests/rust-python-harness/strategies/unit_tests/python_runner.py +++ b/tests/rust-python-harness/shared/unit_runners/python_runner.py @@ -1,7 +1,6 @@ from __future__ import annotations import argparse -import ast import importlib import os import subprocess @@ -9,11 +8,16 @@ import sys import tempfile from collections.abc import Callable, Sequence from pathlib import Path -from typing import Final, Literal, cast +from typing import TYPE_CHECKING, Final, Literal, cast -import pytest +from pluggy import HookimplMarker from pydantic import BaseModel, ConfigDict +if TYPE_CHECKING: + import pytest + +hookimpl: Final = HookimplMarker("pytest") + Backend = Literal["python", "rust"] @@ -32,22 +36,20 @@ class BackendSpec(BaseModel): model_config = ConfigDict(frozen=True, extra="forbid") environment_variable: str + probe: str = "" + + +class WorkerArgs(BaseModel): + model_config = ConfigDict(frozen=True, extra="forbid") + + backend: Backend probe: str - - -def ocr_backend() -> Backend: - from litellm.rust_bridge import native_bridge_available - from litellm.rust_bridge.configuration import rust_ocr_enabled - - if not rust_ocr_enabled(): - return "python" - if not native_bridge_available(): - raise RuntimeError("Rust OCR was enabled but the native extension is unavailable") - return "rust" + output: Path + pytest_args: tuple[str, ...] class ResultPlugin: - def __init__(self, backend: Backend, probe: Callable[[], object]) -> None: + def __init__(self, backend: Backend, probe: Callable[[], object] | None) -> None: self.backend: Final = backend self.probe: Final = probe self.tests: tuple[str, ...] = () @@ -55,13 +57,13 @@ class ResultPlugin: self.problems: tuple[str, ...] = () def verify(self) -> None: - if self.probe() != self.backend: + if self.probe is not None and self.probe() != self.backend: raise RuntimeError(f"backend probe did not select {self.backend}") def pytest_collection_finish(self, session: pytest.Session) -> None: self.tests = tuple(item.nodeid for item in session.items) - @pytest.hookimpl(tryfirst=True) + @hookimpl(tryfirst=True) def pytest_runtest_call(self, item: pytest.Item) -> None: del item self.verify() @@ -91,8 +93,7 @@ def run_python_tests( __name__, "--backend", backend, - "--probe", - spec.probe, + *(("--probe", spec.probe) if spec.probe else ()), "--output", str(output), "--", @@ -118,6 +119,9 @@ def run_python_tests( problems=(result.stdout + result.stderr,), ) report: Final = PythonReport.model_validate_json(output.read_text()) + process_output: Final = (result.stdout + result.stderr).strip() + if result.returncode and not report.problems and process_output: + return report.model_copy(update={"problems": (process_output,)}) if report.exit_code != result.returncode: return report.model_copy( update={ @@ -129,43 +133,42 @@ def run_python_tests( def compare_python_runs(python: PythonReport, rust: PythonReport) -> tuple[str, ...]: + python_only: Final = tuple(sorted(set(python.outcomes) - set(rust.outcomes))) + rust_only: Final = tuple(sorted(set(rust.outcomes) - set(python.outcomes))) return ( *(("backend selection was not verified",) if not python.verified or not rust.verified else ()), *(("Python run used the wrong backend",) if python.backend != "python" else ()), *(("Rust run used the wrong backend",) if rust.backend != "rust" else ()), *(("Python/Rust test inventories differ",) if python.tests != rust.tests else ()), - *(("Python/Rust test outcomes differ",) if sorted(python.outcomes) != sorted(rust.outcomes) else ()), + *(("Python/Rust test outcomes differ",) if python_only or rust_only else ()), + *(f"Python only: {nodeid} [{stage}] {outcome}" for nodeid, stage, outcome in python_only), + *(f"Rust only: {nodeid} [{stage}] {outcome}" for nodeid, stage, outcome in rust_only), + *(f"Python run: {problem}" for problem in python.problems if not python.verified or not python.tests), + *(f"Rust run: {problem}" for problem in rust.problems if not rust.verified or not rust.tests), *(("no Python tests collected",) if not python.tests else ()), - *( - ("Python tests did not all pass",) - if set(python.tests) - != {node for node, phase, status in python.outcomes if phase == "call" and status == "passed"} - else () - ), - *( - ("Rust-enabled Python tests did not all pass",) - if set(rust.tests) - != {node for node, phase, status in rust.outcomes if phase == "call" and status == "passed"} - else () - ), - *(("Python test run failed",) if python.exit_code else ()), - *(("Rust-enabled Python test run failed",) if rust.exit_code else ()), - *python.problems, - *rust.problems, + *(("Python/Rust exit codes differ",) if python.exit_code != rust.exit_code else ()), ) +def _load_probe(reference: str) -> Callable[[], object] | None: + if not reference: + return None + module, name = reference.rsplit(":", 1) + return cast(Callable[[], object], getattr(importlib.import_module(module), name)) + + def main(argv: Sequence[str] | None = None) -> int: + import pytest + parser: Final = argparse.ArgumentParser() parser.add_argument("--backend", required=True, choices=("python", "rust")) - parser.add_argument("--probe", required=True) + parser.add_argument("--probe", default="") parser.add_argument("--output", required=True, type=Path) parser.add_argument("pytest_args", nargs=argparse.REMAINDER) - args: Final = parser.parse_args(argv) + namespace: Final = parser.parse_args(argv) + args: Final = WorkerArgs.model_validate(vars(namespace)) try: - module, name = args.probe.rsplit(":", 1) - probe: Final = cast(Callable[[], object], getattr(importlib.import_module(module), name)) - plugin: Final = ResultPlugin(args.backend, probe) + plugin: Final = ResultPlugin(args.backend, _load_probe(args.probe)) plugin.verify() code: Final = int( pytest.main(["-o", "consider_namespace_packages=true", *args.pytest_args[1:]], plugins=[plugin]) @@ -186,22 +189,29 @@ def main(argv: Sequence[str] | None = None) -> int: return report.exit_code -def enumerate_python_tests(repo_root: Path, relative_path: str) -> frozenset[str]: - source = (repo_root / relative_path).read_text(encoding="utf-8") - tree = ast.parse(source, filename=relative_path) +def contract_nodeid(nodeid: str) -> str: + owner, separator, test = nodeid.rpartition("::") + function: Final = test.partition("[")[0] + if not separator or not function.startswith("test_"): + raise ValueError(f"Unrecognized pytest node id: {nodeid}") + return f"{owner}::{function}" - module_level: list[str] = [] - for node in ast.iter_child_nodes(tree): - if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name.startswith("test_"): - module_level.append(node.name) - elif isinstance(node, ast.ClassDef): - for child in ast.iter_child_nodes(node): - if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)) and child.name.startswith( - "test_" - ): - module_level.append(f"{node.name}::{child.name}") - return frozenset(module_level) +def collect_python_tests(selectors: Sequence[str], repo_root: Path) -> frozenset[str]: + report: Final = run_python_tests( + selectors, + repo_root, + "python", + BackendSpec(environment_variable="LITELLM_RUST"), + ("--collect-only", "-p", "no:cacheprovider"), + ) + if report.exit_code or report.problems: + details: Final = "\n".join(report.problems) or f"pytest exited with code {report.exit_code}" + raise ValueError(f"Python test collection failed:\n{details}") + tests: Final = frozenset(contract_nodeid(nodeid) for nodeid in report.tests) + if not tests: + raise ValueError(f"pytest collected no tests for: {', '.join(selectors)}") + return tests if __name__ == "__main__": diff --git a/tests/rust-python-harness/shared/unit_runners/rust_runner.py b/tests/rust-python-harness/shared/unit_runners/rust_runner.py new file mode 100644 index 00000000000..9df77895519 --- /dev/null +++ b/tests/rust-python-harness/shared/unit_runners/rust_runner.py @@ -0,0 +1,253 @@ +from __future__ import annotations + +import subprocess +from collections.abc import Callable +from dataclasses import dataclass +from itertools import groupby +from pathlib import Path +from typing import Annotated, Final, Literal, TypeAlias + +from pydantic import BaseModel, ConfigDict, Field, model_validator +from typing_extensions import Self + +CommandRunner: TypeAlias = Callable[[tuple[str, ...], Path], str] +_MODEL_CONFIG: Final = ConfigDict(extra="forbid", frozen=True, strict=True) + + +class RustTarget(BaseModel): + model_config = _MODEL_CONFIG + + package: str + name: str + kind: Literal["lib", "bin", "test"] + + @property + def key(self) -> str: + return f"{self.package}/{self.kind}/{self.name}" + + +class RustTestIdentity(BaseModel): + model_config = _MODEL_CONFIG + + target: RustTarget + name: str + + @property + def key(self) -> str: + return f"{self.target.key}::{self.name}" + + +class RustTestScope(BaseModel): + model_config = _MODEL_CONFIG + + target: RustTarget + modules: Annotated[tuple[str, ...], Field(min_length=1)] + features: tuple[str, ...] = () + default_features: bool = True + + @model_validator(mode="after") + def validate_scope(self) -> Self: + duplicate_features: Final = tuple( + feature for feature, values in groupby(sorted(self.features)) if sum(1 for _ in values) > 1 + ) + duplicate_modules: Final = tuple( + module for module, values in groupby(sorted(self.modules)) if sum(1 for _ in values) > 1 + ) + if duplicate_features: + raise ValueError(f"Rust features contain duplicates: {', '.join(duplicate_features)}") + if duplicate_modules: + raise ValueError(f"Rust modules contain duplicates: {', '.join(duplicate_modules)}") + if any(not module or module.endswith("::") for module in self.modules): + raise ValueError("Rust modules must be non-empty and omit the trailing :: separator") + overlaps: Final = tuple( + f"{outer} includes {inner}" + for outer in self.modules + for inner in self.modules + if inner.startswith(f"{outer}::") + ) + if overlaps: + raise ValueError(f"Rust modules overlap: {', '.join(overlaps)}") + return self + + def contains(self, identity: RustTestIdentity) -> bool: + return identity.target == self.target and any( + identity.name.startswith(f"{module}::") for module in self.modules + ) + + +class _CargoPackage(BaseModel): + model_config = ConfigDict(extra="ignore", frozen=True, strict=True) + + id: str + name: str + + +class _CargoMetadata(BaseModel): + model_config = ConfigDict(extra="ignore", frozen=True, strict=True) + + packages: tuple[_CargoPackage, ...] + + +class _CargoMessage(BaseModel): + model_config = ConfigDict(extra="ignore", frozen=True, strict=True) + + reason: str + + +class _CargoTarget(BaseModel): + model_config = ConfigDict(extra="ignore", frozen=True, strict=True) + + name: str + kind: tuple[str, ...] + + +class _CargoProfile(BaseModel): + model_config = ConfigDict(extra="ignore", frozen=True, strict=True) + + test: bool + + +class _CargoArtifact(BaseModel): + model_config = ConfigDict(extra="ignore", frozen=True, strict=True) + + reason: Literal["compiler-artifact"] + package_id: str + target: _CargoTarget + profile: _CargoProfile + executable: str | None + + +@dataclass(frozen=True, slots=True) +class RustReport: + tests: tuple[str, ...] + exit_code: int + output: str + + +def run_command(command: tuple[str, ...], cwd: Path) -> str: + try: + result: Final = subprocess.run(command, cwd=cwd, capture_output=True, text=True, check=False, timeout=600) + except (OSError, subprocess.TimeoutExpired) as error: + raise ValueError(f"Rust inventory command failed: {error}") from error + if result.returncode != 0: + raise ValueError( + f"Rust inventory command failed ({result.returncode}): {' '.join(command)}\n" + f"{result.stderr}\n{result.stdout}" + ) + return result.stdout + + +def run_rust_tests(manifest: Path, package: str | None, test_filter: str, *, collect_only: bool = False) -> RustReport: + command: Final = ( + "cargo", + "test", + "--manifest-path", + str(manifest), + *(("--package", package) if package else ()), + "--lib", + test_filter, + "--", + *(("--list",) if collect_only else ("--format=pretty",)), + ) + try: + result: Final = subprocess.run(command, capture_output=True, text=True, check=False, timeout=600) + except (OSError, subprocess.TimeoutExpired) as error: + return RustReport((), 1, str(error)) + tests: Final = ( + tuple(line.removesuffix(": test") for line in result.stdout.splitlines() if line.endswith(": test")) + if collect_only + else tuple( + line.removeprefix("test ").removesuffix(" ... ok") + for line in result.stdout.splitlines() + if line.startswith("test ") and line.endswith(" ... ok") + ) + ) + return RustReport(tests, result.returncode, result.stdout + result.stderr) + + +def _build_command(scope: RustTestScope) -> tuple[str, ...]: + selector: Final = ("--lib",) if scope.target.kind == "lib" else (f"--{scope.target.kind}", scope.target.name) + features: Final = ("--features", ",".join(scope.features)) if scope.features else () + defaults: Final = () if scope.default_features else ("--no-default-features",) + return ( + "cargo", + "test", + "--package", + scope.target.package, + *selector, + *features, + *defaults, + "--locked", + "--no-run", + "--message-format=json", + "--color", + "never", + ) + + +def _test_names(output: str) -> frozenset[str]: + lines: Final = tuple(line for line in output.splitlines() if line) + invalid: Final = tuple(line for line in lines if not line.endswith((": test", ": benchmark"))) + if invalid: + raise ValueError(f"Unrecognized libtest inventory output: {invalid!r}") + names: Final = tuple(line.removesuffix(": test") for line in lines if line.endswith(": test")) + if len(names) != len(frozenset(names)): + raise ValueError("Duplicate test names in libtest inventory") + return frozenset(names) + + +def _scope_tests( + scope: RustTestScope, + metadata: _CargoMetadata, + cwd: Path, + command_runner: CommandRunner, +) -> frozenset[RustTestIdentity]: + package_ids: Final = tuple(package.id for package in metadata.packages if package.name == scope.target.package) + if len(package_ids) != 1: + raise ValueError(f"Expected one Cargo package for {scope.target.package}, found {len(package_ids)}") + output: Final = command_runner(_build_command(scope), cwd) + artifacts: Final = tuple( + _CargoArtifact.model_validate_json(line) + for line in output.splitlines() + if _CargoMessage.model_validate_json(line).reason == "compiler-artifact" + ) + executables: Final = frozenset( + artifact.executable + for artifact in artifacts + if artifact.package_id == package_ids[0] + and artifact.target.name == scope.target.name + and scope.target.kind in artifact.target.kind + and artifact.profile.test + and artifact.executable is not None + ) + if len(executables) != 1: + raise ValueError(f"Expected one test executable for {scope.target.key}, found {len(executables)}") + executable: Final = next(iter(executables)) + names: Final = _test_names(command_runner((executable, "--list", "--format", "terse"), cwd)) + ignored: Final = _test_names(command_runner((executable, "--list", "--ignored", "--format", "terse"), cwd)) + identities: Final = frozenset(RustTestIdentity(target=scope.target, name=name) for name in names) + scoped: Final = frozenset(identity for identity in identities if scope.contains(identity)) + ignored_scoped: Final = tuple(sorted(identity.key for identity in scoped if identity.name in ignored)) + if ignored_scoped: + raise ValueError(f"Ignored Rust tests cannot satisfy the mapping: {', '.join(ignored_scoped)}") + empty_modules: Final = tuple( + module for module in scope.modules if not any(identity.name.startswith(f"{module}::") for identity in scoped) + ) + if empty_modules: + raise ValueError(f"No compiled tests in {scope.target.key} modules: {', '.join(empty_modules)}") + return scoped + + +def enumerate_rust_tests( + repo_root: Path, + scopes: tuple[RustTestScope, ...], + *, + command_runner: CommandRunner = run_command, +) -> frozenset[RustTestIdentity]: + if not scopes: + return frozenset() + cwd: Final = repo_root / "litellm-rust" + metadata: Final = _CargoMetadata.model_validate_json( + command_runner(("cargo", "metadata", "--format-version", "1", "--no-deps", "--locked"), cwd) + ) + return frozenset(identity for scope in scopes for identity in _scope_tests(scope, metadata, cwd, command_runner)) diff --git a/tests/rust-python-harness/shared/unit_runners/suite_runner.py b/tests/rust-python-harness/shared/unit_runners/suite_runner.py new file mode 100644 index 00000000000..b0374bdf833 --- /dev/null +++ b/tests/rust-python-harness/shared/unit_runners/suite_runner.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass +from pathlib import Path +from time import monotonic +from typing import Final, TypeVar + +from pydantic import BaseModel + +from ..reporting.models import HarnessCase, HarnessRun, ResultArtifact, RunStatus, SdkFunction +from ..reporting.strategy import SuiteCaseSpec, UpdateCallback + +S = TypeVar("S", bound=BaseModel) + + +@dataclass(frozen=True, slots=True) +class SuiteExecution: + problems: tuple[str, ...] = () + artifacts: tuple[ResultArtifact, ...] = () + + +SuiteExecutor = Callable[[S, Path, Sequence[str]], SuiteExecution] + + +def suite_nodeid(case: HarnessCase) -> str: + spec = case.spec + suite = spec.suite if isinstance(spec, SuiteCaseSpec) else "invalid" + return f"suite:{case.strategy_id}:{case.sdk_function}:{suite}" + + +def run_suites( + cases: Sequence[HarnessCase], + repo_root: Path, + on_update: UpdateCallback, + runner_args: Sequence[str] = (), + *, + suites: Mapping[SdkFunction, S], + execute: SuiteExecutor[S], +) -> tuple[int, HarnessRun]: + report = HarnessRun.from_cases(cases) + for case in cases: + result = report.results[case.key] + spec = case.spec + if not isinstance(spec, SuiteCaseSpec): + continue + nodeid = suite_nodeid(case) + result.collected.add(nodeid) + result.status = RunStatus.RUNNING + on_update(report) + suite = suites.get(case.sdk_function) + if suite is None: + result.record(nodeid, RunStatus.ERROR) + report.failures.append((nodeid, f"no suite registered for {case.sdk_function}")) + continue + try: + execution: Final = execute(suite, repo_root, runner_args) + except (OSError, ValueError) as error: + result.record(nodeid, RunStatus.ERROR) + report.failures.append((nodeid, str(error))) + continue + result.record( + nodeid, + RunStatus.FAILED if execution.problems else RunStatus.PASSED, + artifacts=execution.artifacts, + ) + report.failures.extend((nodeid, problem) for problem in execution.problems) + on_update(report) + report.finished_at = monotonic() + on_update(report) + return int( + any( + result.status in {RunStatus.ERROR, RunStatus.FAILED, RunStatus.MISSING} + for result in report.results.values() + ) + ), report diff --git a/tests/rust-python-harness/strategies/unit_tests/test_python_runner.py b/tests/rust-python-harness/shared/unit_runners/test_python_runner.py similarity index 54% rename from tests/rust-python-harness/strategies/unit_tests/test_python_runner.py rename to tests/rust-python-harness/shared/unit_runners/test_python_runner.py index ed4920b7525..abd4316ca98 100644 --- a/tests/rust-python-harness/strategies/unit_tests/test_python_runner.py +++ b/tests/rust-python-harness/shared/unit_runners/test_python_runner.py @@ -4,9 +4,7 @@ import os from pathlib import Path from typing import Final -from .python_runner import BackendSpec, compare_python_runs, run_python_tests - -HARNESS_ROOT: Final = Path(__file__).resolve().parents[4] +from .python_runner import BackendSpec, collect_python_tests, compare_python_runs, run_python_tests def _suite(root: Path, *, mismatch: bool = False) -> BackendSpec: @@ -23,9 +21,7 @@ def _suite(root: Path, *, mismatch: bool = False) -> BackendSpec: return BackendSpec(environment_variable="TEST_USE_RUST", probe="backend_probe:selected") -def test_runs_existing_python_tests_in_separate_verified_backends(tmp_path: Path, monkeypatch) -> None: - monkeypatch.setenv("PYTHONPATH", str(HARNESS_ROOT)) - monkeypatch.setenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD", "1") +def test_runs_existing_python_tests_in_separate_verified_backends(tmp_path: Path) -> None: spec: Final = _suite(tmp_path) python: Final = run_python_tests(("test_backend.py",), tmp_path, "python", spec) rust: Final = run_python_tests(("test_backend.py",), tmp_path, "rust", spec) @@ -35,9 +31,7 @@ def test_runs_existing_python_tests_in_separate_verified_backends(tmp_path: Path assert (tmp_path / "python.pid").read_text() != str(os.getpid()) -def test_rejects_wrong_backend_and_different_test_results(tmp_path: Path, monkeypatch) -> None: - monkeypatch.setenv("PYTHONPATH", str(HARNESS_ROOT)) - monkeypatch.setenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD", "1") +def test_rejects_wrong_backend_and_different_test_results(tmp_path: Path) -> None: spec: Final = _suite(tmp_path, mismatch=True) python: Final = run_python_tests(("test_backend.py",), tmp_path, "python", spec) rust: Final = run_python_tests(("test_backend.py",), tmp_path, "rust", spec) @@ -51,3 +45,44 @@ def test_rejects_wrong_backend_and_different_test_results(tmp_path: Path, monkey assert wrong.exit_code == 1 assert not wrong.verified assert "backend probe did not select rust" in wrong.problems[0] + + +def test_matches_outcomes_without_a_probe_when_both_backends_fail_identically(tmp_path: Path) -> None: + (tmp_path / "pytest.ini").write_text("[pytest]\n") + (tmp_path / "test_backend.py").write_text("def test_fails():\n assert False\n") + spec: Final = BackendSpec(environment_variable="TEST_USE_RUST") + + python: Final = run_python_tests(("test_backend.py",), tmp_path, "python", spec) + rust: Final = run_python_tests(("test_backend.py",), tmp_path, "rust", spec) + + assert compare_python_runs(python, rust) == () + + +def test_reports_worker_output_when_pytest_exits_before_collection(tmp_path: Path) -> None: + report: Final = run_python_tests( + ("missing.py",), + tmp_path, + "python", + BackendSpec(environment_variable="TEST_USE_RUST"), + ) + + assert report.exit_code != 0 + assert report.problems + assert "missing.py" in report.problems[0] + + +def test_collects_tests_with_pytest_semantics_and_collapses_parameters(tmp_path: Path) -> None: + (tmp_path / "pytest.ini").write_text("[pytest]\n") + (tmp_path / "test_inventory.py").write_text( + "import pytest\n" + "class Helper:\n" + " def test_not_collected(self): pass\n" + "class TestCollected:\n" + " @pytest.mark.parametrize('value', [1, 2])\n" + " def test_parameterized(self, value): pass\n", + encoding="utf-8", + ) + + tests: Final = collect_python_tests(("test_inventory.py",), tmp_path) + + assert tests == frozenset(("test_inventory.py::TestCollected::test_parameterized",)) diff --git a/tests/rust-python-harness/shared/unit_runners/test_rust_runner.py b/tests/rust-python-harness/shared/unit_runners/test_rust_runner.py new file mode 100644 index 00000000000..a12d00a7ed6 --- /dev/null +++ b/tests/rust-python-harness/shared/unit_runners/test_rust_runner.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import shutil +from collections.abc import Callable +from pathlib import Path +from typing import Final + +import pytest + +from .rust_runner import RustTarget, RustTestScope, enumerate_rust_tests, run_command, run_rust_tests + + +@pytest.mark.skipif(shutil.which("cargo") is None, reason="Cargo is required for native runner integration") +def test_collects_and_runs_native_tests_and_propagates_failure( + tmp_path: Path, + cargo_project: Callable[[str, str], Path], +) -> None: + manifest: Final = cargo_project("harness-runner-check", "#[test] fn test_parity() { assert_eq!(2 + 2, 4); }\n") + source: Final = tmp_path / "src/lib.rs" + inventory: Final = run_rust_tests(manifest, "harness-runner-check", "test_parity", collect_only=True) + assert inventory.exit_code == 0, inventory.output + assert inventory.tests == ("test_parity",) + passing: Final = run_rust_tests(manifest, "harness-runner-check", "test_parity") + assert passing.exit_code == 0, passing.output + source.write_text("#[test] fn test_parity() { assert_eq!(2 + 2, 5); }\n") + failed: Final = run_rust_tests(manifest, "harness-runner-check", "test_parity") + assert failed.exit_code != 0 + assert "test_parity" in failed.output + + +@pytest.mark.skipif(shutil.which("cargo") is None, reason="Cargo is required for compiled inventory tests") +def test_discovers_compiled_fully_qualified_tests(tmp_path: Path) -> None: + workspace: Final = tmp_path / "litellm-rust" + source: Final = workspace / "src" + external: Final = source / "ocr" / "external.rs" + external.parent.mkdir(parents=True) + (workspace / "Cargo.toml").write_text( + '[package]\nname = "inventory-fixture"\nversion = "0.1.0"\nedition = "2021"\n[workspace]\n', + encoding="utf-8", + ) + (source / "lib.rs").write_text( + "#[cfg(test)]\n" + "mod ocr {\n" + " mod external;\n" + " #[test] fn same_name() {}\n" + " #[cfg(any())] #[test] fn compiled_out() {}\n" + " macro_rules! generate_test { ($name:ident) => { #[test] fn $name() {} }; }\n" + " generate_test!(generated_case);\n" + "}\n", + encoding="utf-8", + ) + external.write_text("#[test] fn same_name() {}\n", encoding="utf-8") + run_command(("cargo", "generate-lockfile", "--offline"), workspace) + target: Final = RustTarget(package="inventory-fixture", name="inventory_fixture", kind="lib") + scope: Final = RustTestScope(target=target, modules=("ocr",)) + + inventory: Final = enumerate_rust_tests(tmp_path, (scope,)) + + assert frozenset(identity.name for identity in inventory) == frozenset( + ("ocr::same_name", "ocr::external::same_name", "ocr::generated_case") + ) diff --git a/tests/rust-python-harness/shared/unit_runners/test_suite_runner.py b/tests/rust-python-harness/shared/unit_runners/test_suite_runner.py new file mode 100644 index 00000000000..92670da666e --- /dev/null +++ b/tests/rust-python-harness/shared/unit_runners/test_suite_runner.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +from collections.abc import Sequence +from pathlib import Path +from typing import Final + +from pydantic import BaseModel + +from ..reporting.models import Coverage, HarnessCase, ResultArtifact, RunStatus +from ..reporting.strategy import CaseSpec, NotImplementedCaseSpec, SuiteCaseSpec +from .suite_runner import SuiteExecution, run_suites + + +class _Suite(BaseModel): + problems: tuple[str, ...] = () + + +def _execute(suite: _Suite, repo_root: Path, pytest_args: Sequence[str]) -> SuiteExecution: + del repo_root, pytest_args + return SuiteExecution(problems=suite.problems) + + +def _case(spec: CaseSpec) -> HarnessCase: + return HarnessCase( + strategy_id="example", + strategy_label="Example", + sdk_function="ocr", + spec=spec, + ) + + +def test_not_implemented_cell_finalizes_without_running(tmp_path: Path) -> None: + case = _case(NotImplementedCaseSpec(reason="No suite is registered.")) + + code, report = run_suites((case,), tmp_path, lambda _: None, (), suites={}, execute=_execute) + + assert code == 0 + assert report.results[case.key].status is RunStatus.NOT_IMPLEMENTED + assert not report.failures + + +def test_missing_registered_suite_marks_the_cell_as_error(tmp_path: Path) -> None: + case = _case(SuiteCaseSpec(coverage=Coverage.COMPLETE, suite="ocr")) + + code, report = run_suites((case,), tmp_path, lambda _: None, (), suites={}, execute=_execute) + + assert code == 1 + assert report.results[case.key].status is RunStatus.ERROR + assert report.failures + + +def test_suite_problems_mark_the_cell_as_failed(tmp_path: Path) -> None: + case = _case(SuiteCaseSpec(coverage=Coverage.COMPLETE, suite="ocr")) + + code, report = run_suites( + (case,), tmp_path, lambda _: None, (), suites={"ocr": _Suite(problems=("boom",))}, execute=_execute + ) + + assert code == 1 + assert report.results[case.key].status is RunStatus.FAILED + assert ("suite:example:ocr:ocr", "boom") in report.failures + + +def test_suite_without_problems_passes(tmp_path: Path) -> None: + case = _case(SuiteCaseSpec(coverage=Coverage.COMPLETE, suite="ocr")) + + code, report = run_suites((case,), tmp_path, lambda _: None, (), suites={"ocr": _Suite()}, execute=_execute) + + assert code == 0 + assert report.results[case.key].status is RunStatus.PASSED + assert not report.failures + + +def test_suite_attaches_artifacts_to_passing_and_failing_results(tmp_path: Path) -> None: + case: Final = _case(SuiteCaseSpec(coverage=Coverage.COMPLETE, suite="ocr")) + artifact: Final = ResultArtifact("example", "body") + + def execute(suite: _Suite, repo_root: Path, pytest_args: Sequence[str]) -> SuiteExecution: + del repo_root, pytest_args + return SuiteExecution(problems=suite.problems, artifacts=(artifact,)) + + _, passing = run_suites((case,), tmp_path, lambda _: None, suites={"ocr": _Suite()}, execute=execute) + _, failing = run_suites( + (case,), tmp_path, lambda _: None, suites={"ocr": _Suite(problems=("boom",))}, execute=execute + ) + + assert passing.results[case.key].artifacts == {"suite:example:ocr:ocr": (artifact,)} + assert failing.results[case.key].artifacts == {"suite:example:ocr:ocr": (artifact,)} diff --git a/tests/rust-python-harness/strategies/e2e_parity/AGENTS.md b/tests/rust-python-harness/strategies/e2e_parity/AGENTS.md new file mode 100644 index 00000000000..27c87d0f100 --- /dev/null +++ b/tests/rust-python-harness/strategies/e2e_parity/AGENTS.md @@ -0,0 +1 @@ +Switches between the Rust implementation and the existing Python core, then compares their observable behavior for parity across SDK objects, exceptions, callbacks, streams, and gateway HTTP responses using generated and recorded inputs. diff --git a/tests/rust-python-harness/strategies/e2e_parity/README.md b/tests/rust-python-harness/strategies/e2e_parity/README.md deleted file mode 100644 index 17643e69676..00000000000 --- a/tests/rust-python-harness/strategies/e2e_parity/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# E2E Parity - -Run independently with `uv run python -m tests.rust-python-harness.strategies.e2e_parity.runner --plain`. Configure SDK and gateway selectors in `strategy.json`; keep API-specific execution and fixtures in their owning surface folder - -See [the harness guide](../../README.md) for coverage status and shared comparison tools diff --git a/tests/rust-python-harness/strategies/e2e_parity/__init__.py b/tests/rust-python-harness/strategies/e2e_parity/__init__.py index e69de29bb2d..f668e178eef 100644 --- a/tests/rust-python-harness/strategies/e2e_parity/__init__.py +++ b/tests/rust-python-harness/strategies/e2e_parity/__init__.py @@ -0,0 +1,103 @@ +from pathlib import Path +from typing import Final + +from ...shared.reporting.models import SURFACES, Coverage +from ...shared.reporting.strategy import ( + CaseDefinition, + ModuleCaseSpec, + NotImplementedCaseSpec, + StrategyDefinition, +) +from .reporting import render_e2e_results +from .runner import run_e2e_cases + +CASES: Final[tuple[CaseDefinition, ...]] = ( + CaseDefinition( + "ocr", + ModuleCaseSpec( + coverage=Coverage.PARTIAL, + module="tests.rust-python-harness.strategies.e2e_parity.sdk.ocr.test_sdk_parity", + note=( + "Recorded sync/async SDK parity; invalid-model provider errors differ, " + "and Reducto lacks a Rust contract." + ), + ), + surface="sdk", + ), + CaseDefinition( + "messages", + NotImplementedCaseSpec( + reason="Bridge unit tests exist, but no standalone end-to-end parity case is registered." + ), + surface="sdk", + ), + CaseDefinition( + "responses", + NotImplementedCaseSpec( + reason="Bridge unit tests exist, but no standalone end-to-end parity case is registered." + ), + surface="sdk", + ), + CaseDefinition( + "count_tokens", + NotImplementedCaseSpec(reason="No Rust count_tokens parity test is present yet."), + surface="sdk", + ), + CaseDefinition( + "chat_completions", + NotImplementedCaseSpec( + reason="Bridge unit tests exist, but no standalone end-to-end parity case is registered." + ), + surface="sdk", + ), + CaseDefinition( + "transcription", + NotImplementedCaseSpec( + reason="Bridge unit tests exist, but no standalone end-to-end parity case is registered." + ), + surface="sdk", + ), + CaseDefinition( + "ocr", + NotImplementedCaseSpec(reason="No gateway end-to-end OCR parity case is registered."), + surface="gateway", + ), + CaseDefinition( + "messages", + NotImplementedCaseSpec(reason="No gateway end-to-end Messages parity case is registered."), + surface="gateway", + ), + CaseDefinition( + "responses", + NotImplementedCaseSpec(reason="No gateway end-to-end Responses parity case is registered."), + surface="gateway", + ), + CaseDefinition( + "count_tokens", + NotImplementedCaseSpec(reason="No gateway end-to-end token-count parity case is registered."), + surface="gateway", + ), + CaseDefinition( + "chat_completions", + NotImplementedCaseSpec(reason="No gateway end-to-end chat parity case is registered."), + surface="gateway", + ), + CaseDefinition( + "transcription", + NotImplementedCaseSpec(reason="No gateway end-to-end transcription parity case is registered."), + surface="gateway", + ), +) + +STRATEGY: Final = StrategyDefinition( + id="e2e_parity", + order=10, + label="End-to-end parity", + description="Compare observable Python and Rust behavior over generated and recorded inputs.", + directory=Path(__file__).parent, + runnable_spec=ModuleCaseSpec, + cases=CASES, + run=run_e2e_cases, + render=render_e2e_results, + surfaces=SURFACES, +) diff --git a/tests/rust-python-harness/strategies/e2e_parity/reporting.py b/tests/rust-python-harness/strategies/e2e_parity/reporting.py new file mode 100644 index 00000000000..d1a5c389bdf --- /dev/null +++ b/tests/rust-python-harness/strategies/e2e_parity/reporting.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +from collections.abc import Sequence +from typing import Final + +from ...shared.reporting.models import CaseResult +from ...shared.reporting.rendering import ReportSection, render_case_outcome + + +def render_e2e_results(results: Sequence[CaseResult]) -> tuple[ReportSection, ...]: + blocks: Final = tuple(render_case_outcome(result) for result in results) + return (ReportSection("End-to-end parity outcomes", blocks or ("No end-to-end cases selected",)),) diff --git a/tests/rust-python-harness/strategies/e2e_parity/runner.py b/tests/rust-python-harness/strategies/e2e_parity/runner.py index 2886c823370..109df68e2ec 100644 --- a/tests/rust-python-harness/strategies/e2e_parity/runner.py +++ b/tests/rust-python-harness/strategies/e2e_parity/runner.py @@ -1,26 +1,125 @@ from __future__ import annotations -from collections.abc import Sequence +import importlib +from collections.abc import Callable, Sequence +from contextlib import AbstractContextManager, nullcontext +from dataclasses import dataclass from pathlib import Path +from time import monotonic +from typing import Final, cast -from ...shared.reporting.models import HarnessCase, HarnessRun -from ...shared.reporting.pytest_runner import UpdateCallback, run_pytest +from ...shared.reporting.models import CaseResult, HarnessCase, HarnessRun, RunStatus +from ...shared.reporting.strategy import ModuleCaseSpec, UpdateCallback -def run( +@dataclass(frozen=True, slots=True) +class E2ECheck: + name: str + execute: Callable[[], None] + + +@dataclass(frozen=True, slots=True) +class E2ELoadFailure: + message: str + + +def _load_checks(reference: str) -> AbstractContextManager[object] | E2ELoadFailure: + try: + module: Final = importlib.import_module(reference) + factory_value: Final[object] = getattr(module, "parity_checks", None) + if not callable(factory_value): + return E2ELoadFailure(f"{reference} must export parity_checks()") + factory: Final = cast(Callable[[], object], factory_value) + checks_value: Final = factory() + except Exception as error: + return E2ELoadFailure(f"cannot load {reference}: {type(error).__name__}: {error}") + if isinstance(checks_value, tuple): + return nullcontext(cast(object, checks_value)) + if isinstance(checks_value, AbstractContextManager): + return cast(AbstractContextManager[object], checks_value) + return E2ELoadFailure( + f"{reference}.parity_checks() must return tuple[E2ECheck, ...] or a context manager yielding one" + ) + + +def _validate_checks(reference: str, checks_value: object) -> tuple[E2ECheck, ...] | E2ELoadFailure: + if not isinstance(checks_value, tuple): + return E2ELoadFailure(f"{reference}.parity_checks() context manager must yield tuple[E2ECheck, ...]") + untyped_checks: Final = cast(tuple[object, ...], checks_value) + if not all(isinstance(check, E2ECheck) for check in untyped_checks): + return E2ELoadFailure(f"{reference}.parity_checks() must return tuple[E2ECheck, ...]") + return cast(tuple[E2ECheck, ...], untyped_checks) + + +def _run_check( + run: HarnessRun, + result: CaseResult, + check: E2ECheck, + nodeid: str, + on_update: UpdateCallback, +) -> None: + started_at: Final = monotonic() + try: + check.execute() + except Exception as error: + result.record(nodeid, RunStatus.FAILED, monotonic() - started_at) + run.failures.append((nodeid, f"{type(error).__name__}: {error}")) + else: + result.record(nodeid, RunStatus.PASSED, monotonic() - started_at) + on_update(run) + + +def _run_case(run: HarnessRun, harness_case: HarnessCase, on_update: UpdateCallback) -> None: + result: Final = run.results[harness_case.key] + spec: Final = harness_case.spec + if not isinstance(spec, ModuleCaseSpec): + return + loaded: Final = _load_checks(spec.module) + if isinstance(loaded, E2ELoadFailure): + load_nodeid: Final = f"e2e:{harness_case.surface}:{harness_case.sdk_function}:load" + result.collected.add(load_nodeid) + result.record(load_nodeid, RunStatus.ERROR) + run.failures.append((load_nodeid, loaded.message)) + on_update(run) + return + try: + with loaded as checks_value: + checks: Final = _validate_checks(spec.module, checks_value) + if isinstance(checks, E2ELoadFailure): + raise TypeError(checks.message) + nodeids: Final = tuple( + (check, f"e2e:{harness_case.surface}:{harness_case.sdk_function}:{check.name}") for check in checks + ) + result.collected.update(nodeid for _, nodeid in nodeids) + if not nodeids: + result.status = RunStatus.SKIPPED + on_update(run) + return + result.status = RunStatus.RUNNING + on_update(run) + for check, nodeid in nodeids: + _run_check(run, result, check, nodeid, on_update) + except Exception as error: + session_nodeid: Final = f"e2e:{harness_case.surface}:{harness_case.sdk_function}:session" + result.collected.add(session_nodeid) + result.record(session_nodeid, RunStatus.ERROR) + run.failures.append((session_nodeid, f"{type(error).__name__}: {error}")) + on_update(run) + + +def run_e2e_cases( cases: Sequence[HarnessCase], repo_root: Path, on_update: UpdateCallback, - pytest_args: Sequence[str] = (), + runner_args: Sequence[str] = (), ) -> tuple[int, HarnessRun]: - return run_pytest(cases, repo_root, on_update, pytest_args) - - -def main(argv: Sequence[str] | None = None) -> int: - from ...cli import main as harness_main - - return harness_main(argv, strategy_id="e2e_parity") - - -if __name__ == "__main__": - raise SystemExit(main()) + del repo_root, runner_args + run: Final = HarnessRun.from_cases(cases) + for harness_case in cases: + _run_case(run, harness_case, on_update) + run.finished_at = monotonic() + on_update(run) + failed: Final = any( + result.status in {RunStatus.ERROR, RunStatus.FAILED, RunStatus.MISSING} for result in run.results.values() + ) + return int(failed), run diff --git a/tests/rust-python-harness/strategies/e2e_parity/sdk/__init__.py b/tests/rust-python-harness/strategies/e2e_parity/sdk/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/tests/rust-python-harness/strategies/e2e_parity/sdk/chat_completions/__init__.py b/tests/rust-python-harness/strategies/e2e_parity/sdk/chat_completions/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/tests/rust-python-harness/strategies/e2e_parity/sdk/messages/__init__.py b/tests/rust-python-harness/strategies/e2e_parity/sdk/messages/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/tests/rust-python-harness/strategies/e2e_parity/sdk/ocr/__init__.py b/tests/rust-python-harness/strategies/e2e_parity/sdk/ocr/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/tests/rust-python-harness/strategies/e2e_parity/sdk/ocr/fixtures/config.py b/tests/rust-python-harness/strategies/e2e_parity/sdk/ocr/fixtures/config.py index 2d65189790a..ffd8d903551 100644 --- a/tests/rust-python-harness/strategies/e2e_parity/sdk/ocr/fixtures/config.py +++ b/tests/rust-python-harness/strategies/e2e_parity/sdk/ocr/fixtures/config.py @@ -6,6 +6,8 @@ from collections.abc import Callable, Mapping from pathlib import Path from typing import Final +from ......shared.parity.fixtures.store import fixture_directory + FIXTURE_DIR_ENV: Final = "LITELLM_OCR_FIXTURE_DIR" DEFAULT_FIXTURE_DIRECTORY: Final = Path(__file__).with_name("data") @@ -40,5 +42,4 @@ def recording_environment( 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 + return fixture_directory(None, os.environ.get(FIXTURE_DIR_ENV), DEFAULT_FIXTURE_DIRECTORY) diff --git a/tests/rust-python-harness/strategies/e2e_parity/sdk/ocr/fixtures/reducto.py b/tests/rust-python-harness/strategies/e2e_parity/sdk/ocr/fixtures/reducto.py index fe8fab50518..bcca8ac6d42 100644 --- a/tests/rust-python-harness/strategies/e2e_parity/sdk/ocr/fixtures/reducto.py +++ b/tests/rust-python-harness/strategies/e2e_parity/sdk/ocr/fixtures/reducto.py @@ -89,13 +89,19 @@ ReductoBlockType = Literal[ "Comment", "Signature", ] -_REDUCTO_FILTER_BLOCK_GROUPS: Final[tuple[tuple[ReductoBlockType, ...], ...]] = ( +REDUCTO_FORMATTING_INCLUDE_GROUPS: Final[tuple[tuple[ReductoFormattingInclude, ...], ...]] = ( + (), + ("hyperlinks",), + ("change_tracking", "highlight", "comments"), + ("signatures", "ignore_watermarks"), +) +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, ...], ...]] = ( +REDUCTO_RETURN_IMAGE_GROUPS: Final[tuple[tuple[ReductoReturnImage, ...], ...]] = ( (), ("figure",), ("table",), @@ -258,14 +264,7 @@ def _formatting_strategy() -> SearchStrategy[ReductoFormatting]: ), 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"), - ) - ) + st.sampled_from(REDUCTO_FORMATTING_INCLUDE_GROUPS) .map(list) .map(lambda value: {"include": value}), ) @@ -288,7 +287,7 @@ def _chunking_strategy() -> SearchStrategy[ReductoChunking]: def _retrieval_strategy() -> SearchStrategy[ReductoRetrieval]: filter_blocks: Final = cast( SearchStrategy[list[ReductoBlockType]], - st.sampled_from(_REDUCTO_FILTER_BLOCK_GROUPS).map(list), + st.sampled_from(REDUCTO_FILTER_BLOCK_GROUPS).map(list), ) return st.one_of( _chunking_strategy().map(lambda chunking: ReductoRetrieval(chunking=chunking)), @@ -305,7 +304,7 @@ def _retrieval_strategy() -> SearchStrategy[ReductoRetrieval]: 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( + return_images: Final[SearchStrategy[list[ReductoReturnImage]]] = st.sampled_from(REDUCTO_RETURN_IMAGE_GROUPS).map( list ) page_ranges: Final = st.one_of( diff --git a/tests/rust-python-harness/strategies/e2e_parity/sdk/ocr/test_fixture_models.py b/tests/rust-python-harness/strategies/e2e_parity/sdk/ocr/test_fixture_models.py index bdccddd9cfa..80b830369e6 100644 --- a/tests/rust-python-harness/strategies/e2e_parity/sdk/ocr/test_fixture_models.py +++ b/tests/rust-python-harness/strategies/e2e_parity/sdk/ocr/test_fixture_models.py @@ -4,16 +4,16 @@ import base64 from collections.abc import Callable from datetime import date from pathlib import Path -from typing import Final, TypeVar, cast +from typing import Final, cast from unittest.mock import patch from urllib.parse import parse_qs, urlparse import httpx import pytest import respx -from hypothesis import find, given, settings +from hypothesis import given, settings from hypothesis import strategies as st -from hypothesis.strategies import DataObject, SearchStrategy +from hypothesis.strategies import DataObject from pydantic import BaseModel, ConfigDict, JsonValue, TypeAdapter, ValidationError from litellm.llms.azure_ai.ocr.document_intelligence.transformation import AzureDocumentIntelligenceOCRConfig @@ -27,6 +27,7 @@ from litellm.llms.mistral.ocr.transformation import MistralOCRConfig from litellm.llms.reducto.ocr.transformation import ReductoParseLegacyConfig, ReductoParseV3Config from litellm.llms.vertex_ai.ocr.deepseek_transformation import VertexAIDeepSeekOCRConfig from litellm.llms.vertex_ai.ocr.transformation import VertexAIOCRConfig + from .....shared.parity.fixtures.media import structured_pdf_data_uri from .conftest import ocr_fixture_marks from .fixtures.azure import ( @@ -49,7 +50,10 @@ from .fixtures.base import ( from .fixtures.mistral import MISTRAL_MODELS, MistralOcrSdkInput, mistral_input_strategy from .fixtures.models import OcrParityCase, OcrSdkInput from .fixtures.reducto import ( + REDUCTO_FILTER_BLOCK_GROUPS, + REDUCTO_FORMATTING_INCLUDE_GROUPS, REDUCTO_LEGACY_MODELS, + REDUCTO_RETURN_IMAGE_GROUPS, REDUCTO_V3_MODELS, ReductoChunking, ReductoDocumentUrlDocument, @@ -71,6 +75,7 @@ from .fixtures.vertex import ( vertex_deepseek_input_strategy, vertex_mistral_input_strategy, ) +from .test_support import find_fixture as _find_fixture COMMON_FIELDS: Final = frozenset( {"contract", "model", "document", "custom_llm_provider", "vertex_project", "vertex_location"} @@ -150,27 +155,6 @@ _MISTRAL_2505_OPTION_GROUPS: Final = frozenset( _AZURE_MISTRAL_OPTION_GROUPS: Final = _MISTRAL_2505_OPTION_GROUPS - { frozenset({"document_annotation_format", "document_annotation_prompt"}) } -_REDUCTO_FORMATTING_INCLUDE_GROUPS: Final = ( - (), - ("hyperlinks",), - ("change_tracking", "highlight", "comments"), - ("signatures", "ignore_watermarks"), -) -_REDUCTO_FILTER_BLOCK_GROUPS: Final = ( - (), - ("Header",), - ("Header", "Footer", "Page Number"), - ("Figure", "Table", "Key Value"), -) -_REDUCTO_RETURN_IMAGE_GROUPS: Final = ( - (), - ("figure",), - ("table",), - ("page",), - ("figure", "table"), -) -_FIND_SETTINGS: Final = settings(max_examples=2_000, deadline=None, derandomize=True, database=None) -_FixtureInputT = TypeVar("_FixtureInputT") INLINE_IMAGE_DATA_URI: Final = "data:image/png;base64,dGVzdA==" _MapOcrParams = Callable[[dict[str, object], dict[str, object], str], dict[str, object]] _TransformOcrRequest = Callable[ @@ -198,13 +182,6 @@ def _transform_with_stubbed_download( return transform_request(model, document, mapped, {}) -def _find_fixture( - strategy: SearchStrategy[_FixtureInputT], - predicate: Callable[[_FixtureInputT], bool], -) -> _FixtureInputT: - return find(strategy, predicate, settings=_FIND_SETTINGS) - - def _document_transport(document: ImageUrlDocument | DocumentUrlDocument) -> tuple[str, str]: if isinstance(document, ImageUrlDocument): source: Final = document.image_url.url if isinstance(document.image_url, ImageUrlValue) else document.image_url @@ -701,7 +678,7 @@ def test_reducto_v3_strategy_only_generates_bounded_valid_sdk_inputs(sdk_input: if "merge_tables" in formatting_fields: assert sdk_input.formatting.merge_tables in {False, True} if "include" in formatting_fields: - assert tuple(sdk_input.formatting.include) in _REDUCTO_FORMATTING_INCLUDE_GROUPS + assert tuple(sdk_input.formatting.include) in REDUCTO_FORMATTING_INCLUDE_GROUPS if "retrieval" in option_groups: retrieval_fields: Final = frozenset(sdk_input.retrieval.model_fields_set) assert retrieval_fields in { @@ -719,7 +696,7 @@ def test_reducto_v3_strategy_only_generates_bounded_valid_sdk_inputs(sdk_input: if chunking.chunk_overlap: assert chunking.chunk_size == 1000 if "filter_blocks" in retrieval_fields: - assert tuple(sdk_input.retrieval.filter_blocks) in _REDUCTO_FILTER_BLOCK_GROUPS + assert tuple(sdk_input.retrieval.filter_blocks) in REDUCTO_FILTER_BLOCK_GROUPS if "embedding_optimized" in retrieval_fields: assert chunking.chunk_mode == "variable" assert chunking.chunk_size is None @@ -757,7 +734,7 @@ def test_reducto_v3_strategy_only_generates_bounded_valid_sdk_inputs(sdk_input: if "return_ocr_data" in settings_fields: assert sdk_input.settings.return_ocr_data is True if "return_images" in settings_fields: - assert tuple(sdk_input.settings.return_images) in _REDUCTO_RETURN_IMAGE_GROUPS + assert tuple(sdk_input.settings.return_images) in REDUCTO_RETURN_IMAGE_GROUPS if "embed_pdf_metadata_dpi" in settings_fields: assert sdk_input.settings.embed_pdf_metadata is True assert sdk_input.settings.embed_pdf_metadata_dpi in {50, 100, 250} @@ -859,7 +836,7 @@ def test_reducto_v3_strategy_reaches_every_formatting_boolean(field: str, value: assert getattr(sdk_input.formatting, field) is value -@pytest.mark.parametrize("include", _REDUCTO_FORMATTING_INCLUDE_GROUPS) +@pytest.mark.parametrize("include", REDUCTO_FORMATTING_INCLUDE_GROUPS) def test_reducto_v3_strategy_reaches_every_formatting_include(include: tuple[str, ...]) -> None: sdk_input: Final = _find_fixture( reducto_v3_input_strategy(INLINE_IMAGE_DATA_URI), @@ -910,7 +887,7 @@ def test_reducto_v3_strategy_reaches_every_chunk_overlap(chunk_overlap: int) -> assert sdk_input.retrieval.chunking.chunk_overlap == chunk_overlap -@pytest.mark.parametrize("filter_blocks", _REDUCTO_FILTER_BLOCK_GROUPS) +@pytest.mark.parametrize("filter_blocks", REDUCTO_FILTER_BLOCK_GROUPS) def test_reducto_v3_strategy_reaches_every_filter_block_group(filter_blocks: tuple[str, ...]) -> None: sdk_input: Final = _find_fixture( reducto_v3_input_strategy(INLINE_IMAGE_DATA_URI), @@ -992,7 +969,7 @@ def test_reducto_v3_strategy_reaches_every_scalar_setting(field: str, value: obj assert getattr(sdk_input.settings, field) == value -@pytest.mark.parametrize("return_images", _REDUCTO_RETURN_IMAGE_GROUPS) +@pytest.mark.parametrize("return_images", REDUCTO_RETURN_IMAGE_GROUPS) def test_reducto_v3_strategy_reaches_every_return_image_group(return_images: tuple[str, ...]) -> None: sdk_input: Final = _find_fixture( reducto_v3_input_strategy(INLINE_IMAGE_DATA_URI), diff --git a/tests/rust-python-harness/strategies/e2e_parity/sdk/ocr/test_record_fixtures.py b/tests/rust-python-harness/strategies/e2e_parity/sdk/ocr/test_record_fixtures.py index 6c6dcea17d1..bc770131d85 100644 --- a/tests/rust-python-harness/strategies/e2e_parity/sdk/ocr/test_record_fixtures.py +++ b/tests/rust-python-harness/strategies/e2e_parity/sdk/ocr/test_record_fixtures.py @@ -7,7 +7,6 @@ from pathlib import Path from typing import Final, cast import pytest -from hypothesis import find, settings from hypothesis.strategies import SearchStrategy from .....shared.parity.fixtures.cli import parse_recording_args @@ -41,6 +40,7 @@ from .fixtures.vertex import ( vertex_deepseek_provider_rejected_inputs, vertex_mistral_provider_rejected_inputs, ) +from .test_support import find_fixture class _UnusedOcrClient: @@ -76,7 +76,6 @@ _MISTRAL_PARAMS: Final = frozenset( _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==" @@ -94,7 +93,7 @@ def _find_input( strategy: SearchStrategy[OcrSdkInputBase], predicate: Callable[[OcrSdkInputBase], bool], ) -> OcrSdkInputBase: - return find(strategy, predicate, settings=_FIND_SETTINGS) + return find_fixture(strategy, predicate) def _document_transport(case_input: OcrSdkInputBase) -> tuple[str, str]: diff --git a/tests/rust-python-harness/strategies/e2e_parity/sdk/ocr/test_sdk_parity.py b/tests/rust-python-harness/strategies/e2e_parity/sdk/ocr/test_sdk_parity.py index bedbdeb6a13..e72980752f2 100644 --- a/tests/rust-python-harness/strategies/e2e_parity/sdk/ocr/test_sdk_parity.py +++ b/tests/rust-python-harness/strategies/e2e_parity/sdk/ocr/test_sdk_parity.py @@ -2,23 +2,21 @@ from __future__ import annotations import asyncio import sys +import tempfile import traceback -from collections.abc import Awaitable, Callable, Coroutine, Generator +from collections.abc import Callable, Coroutine, Generator from contextlib import contextmanager -from dataclasses import dataclass from enum import Enum +from functools import partial from pathlib import Path -from typing import Final, cast +from typing import Annotated, Final, Literal, cast -import pytest +from pydantic import BaseModel, ConfigDict, Field, JsonValue, TypeAdapter 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 .....shared.parity.compare import assert_model_parity, assert_parity, assert_request_parity -from .....shared.parity.fixtures.store import recorded_fixtures -from .....shared.parity.inprocess import run_in_process + +from .....shared.parity.compare import assert_parity +from .....shared.parity.fixtures.store import fixture_id, recorded_fixtures from .....shared.parity.models import ( SDKCommand, SDKError, @@ -29,22 +27,21 @@ from .....shared.parity.models import ( WorkerSuccess, sdk_error_report, ) -from .....shared.parity.replay import replay_server from .....shared.parity.runner import ( ExecutionVariant, SubprocessRunner, SubprocessWorker, execution_worker_pair, parity_worker_main, - run_execution, ) +from ...runner import E2ECheck from .fixtures.config import configured_fixture_directory from .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"),)) +PYTHON_VARIANT: Final = ExecutionVariant(name="Python", environment=(("LITELLM_RUST", "0"),)) +RUST_VARIANT: Final = ExecutionVariant(name="Rust", environment=(("LITELLM_RUST", "1"),)) class SDKRoute(str, Enum): @@ -52,16 +49,34 @@ class SDKRoute(str, Enum): AOCR = "aocr" -@dataclass(frozen=True, slots=True) -class InvalidOcrCase: +class InvalidOcrCase(BaseModel): + model_config = ConfigDict(frozen=True) + name: str model: str - document: object + document: JsonValue expected_exception_type: str expected_status_code: int expected_message: str - extra_kwargs: tuple[tuple[str, object], ...] = () - expected_rust_calls: int = 0 + extra_kwargs: tuple[tuple[str, JsonValue], ...] = () + + +class RecordedOcrWorkerCase(BaseModel): + model_config = ConfigDict(frozen=True) + + kind: Literal["recorded"] = "recorded" + case: OcrParityCase + + +class InvalidOcrWorkerCase(BaseModel): + model_config = ConfigDict(frozen=True) + + kind: Literal["invalid"] = "invalid" + case: InvalidOcrCase + + +OcrWorkerCase = Annotated[RecordedOcrWorkerCase | InvalidOcrWorkerCase, Field(discriminator="kind")] +OCR_WORKER_CASE_ADAPTER: Final[TypeAdapter[OcrWorkerCase]] = TypeAdapter(OcrWorkerCase) INVALID_OCR_CASES: Final = ( @@ -120,7 +135,6 @@ INVALID_OCR_CASES: Final = ( 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", @@ -129,7 +143,6 @@ INVALID_OCR_CASES: Final = ( 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", @@ -199,32 +212,13 @@ def _execute_sdk_case( 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 = { + call_kwargs: Final[dict[str, object]] = { "model": case.model, "document": case.document, "api_base": mock_url, @@ -235,204 +229,93 @@ def _execute_invalid_sdk_case( 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 _check_recorded_ocr_sdk_parity( + ocr_fixture: OcrParityCase, + route: SDKRoute, + case_file: Path, + sdk_workers: tuple[SubprocessWorker, SubprocessWorker], +) -> None: + python_worker, rust_worker = sdk_workers + python: Final = python_worker.execute(case_file, route.value, ocr_fixture.provider_responses) + rust: Final = rust_worker.execute(case_file, route.value, ocr_fixture.provider_responses) - 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, - ) + assert_parity(python, rust, PYTHON_HTTP_SENTINEL) + if any(response.status_code >= 400 for response in ocr_fixture.provider_responses): + assert isinstance(python.report, SDKError) -class _RustAocrSpy: - def __init__(self, delegate: RustAocr) -> None: - self.delegate: Final = delegate - self.calls = 0 +def _check_invalid_ocr_sdk_parity( + case: InvalidOcrCase, + route: SDKRoute, + case_file: Path, + sdk_workers: tuple[SubprocessWorker, SubprocessWorker], +) -> None: + python_worker, rust_worker = sdk_workers + python: Final = python_worker.execute(case_file, route.value, ()) + rust: Final = rust_worker.execute(case_file, route.value, ()) - 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 + assert_parity(python, rust, PYTHON_HTTP_SENTINEL) + assert python.requests == () + assert rust.requests == () + assert isinstance(python.report, SDKError) + assert python.report.exception_type == case.expected_exception_type + assert python.report.status_code == case.expected_status_code + assert case.expected_message in python.report.message + + +def _recorded_check_name(fixture: OcrParityCase, route: SDKRoute) -> 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 f"recorded:{route.value}:{fixture_id(case_input, prefix)}" + + +def _write_worker_case(directory: Path, index: int, case: OcrWorkerCase) -> Path: + case_file: Final = directory / f"case-{index}.json" + case_file.write_text(OCR_WORKER_CASE_ADAPTER.dump_json(case).decode("utf-8"), encoding="utf-8") + return case_file @contextmanager -def _restore_rust_ocr_state() -> Generator[None]: - enabled: Final = rust_ocr_bridge.rust_ocr_enabled() - ocr_impl: Final = rust_ocr_bridge._rust_ocr_impl # pyright: ignore[reportPrivateUsage] # preserve injected test binding - aocr_impl: Final = rust_ocr_bridge._rust_aocr_impl # pyright: ignore[reportPrivateUsage] # preserve injected test binding - try: - yield - finally: - rust_ocr_bridge.use_litellm_rust(enabled, ocr=ocr_impl, aocr=aocr_impl) - - -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]]: +def parity_checks() -> Generator[tuple[E2ECheck, ...]]: + fixtures: Final = tuple( + fixture + for fixture in recorded_fixtures(configured_fixture_directory(), OcrParityCase) + if fixture.litellm_input.contract not in {"reducto_v3", "reducto_legacy"} + ) 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.use_litellm_rust(False, 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), + with tempfile.TemporaryDirectory(prefix="litellm-ocr-parity-") as raw_directory: + directory: Final = Path(raw_directory) + recorded_files: Final = tuple( + _write_worker_case(directory, index, RecordedOcrWorkerCase(case=fixture)) + for index, fixture in enumerate(fixtures) + ) + invalid_files: Final = tuple( + _write_worker_case(directory, len(recorded_files) + index, InvalidOcrWorkerCase(case=case)) + for index, case in enumerate(INVALID_OCR_CASES) + ) + with execution_worker_pair(runner, PYTHON_VARIANT, RUST_VARIANT) as workers: + recorded: Final = tuple( + E2ECheck( + _recorded_check_name(fixture, route), + partial(_check_recorded_ocr_sdk_parity, fixture, route, case_file, workers), + ) + for fixture, case_file in zip(fixtures, recorded_files, strict=True) + for route in SDKRoute ) - 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), + invalid: Final = tuple( + E2ECheck( + f"invalid:{route.value}:{case.name}", + partial(_check_invalid_ocr_sdk_parity, case, route, case_file, workers), + ) + for case, case_file in zip(INVALID_OCR_CASES, invalid_files, strict=True) + for route in SDKRoute ) - 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.use_litellm_rust(False, 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) + yield (*recorded, *invalid) def _execute_worker_command( @@ -444,8 +327,12 @@ def _execute_worker_command( 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)) + worker_case: Final = OCR_WORKER_CASE_ADAPTER.validate_json(case_file.read_bytes()) + match worker_case: + case RecordedOcrWorkerCase(case=recorded): + return WorkerSuccess(report=_execute_sdk_case(recorded.litellm_input, route, mock_url, event_loop)) + case InvalidOcrWorkerCase(case=invalid): + return WorkerSuccess(report=_execute_invalid_sdk_case(invalid, route, mock_url, event_loop)) except Exception: return WorkerFailure(error=traceback.format_exc()) diff --git a/tests/rust-python-harness/strategies/e2e_parity/sdk/ocr/test_support.py b/tests/rust-python-harness/strategies/e2e_parity/sdk/ocr/test_support.py new file mode 100644 index 00000000000..b6526fb12f5 --- /dev/null +++ b/tests/rust-python-harness/strategies/e2e_parity/sdk/ocr/test_support.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from collections.abc import Callable +from typing import TypeVar + +from hypothesis import find, settings +from hypothesis.strategies import SearchStrategy + +FixtureT = TypeVar("FixtureT") +FIND_SETTINGS = settings(max_examples=2_000, deadline=None, derandomize=True, database=None) + + +def find_fixture( + strategy: SearchStrategy[FixtureT], + predicate: Callable[[FixtureT], bool], +) -> FixtureT: + return find(strategy, predicate, settings=FIND_SETTINGS) diff --git a/tests/rust-python-harness/strategies/e2e_parity/sdk/responses/__init__.py b/tests/rust-python-harness/strategies/e2e_parity/sdk/responses/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/tests/rust-python-harness/strategies/e2e_parity/strategy.json b/tests/rust-python-harness/strategies/e2e_parity/strategy.json deleted file mode 100644 index d791b9373aa..00000000000 --- a/tests/rust-python-harness/strategies/e2e_parity/strategy.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "order": 10, - "id": "e2e_parity", - "label": "End-to-end parity", - "description": "Compare observable Python and Rust SDK behavior over generated and recorded inputs.", - "functions": { - "ocr": { - "coverage": "partial", - "selectors": [ - "tests/rust-python-harness/strategies/e2e_parity/sdk/ocr/test_sdk_parity.py", - "tests/test_litellm/ocr/test_rust_bridge.py" - ], - "note": "Recorded sync/async SDK parity; invalid-model provider errors differ, and Reducto lacks a Rust contract." - }, - "messages": { - "coverage": "partial", - "selectors": [ - "tests/test_litellm/anthropic_interface/test_rust_bridge_messages.py" - ], - "note": "Bridge coverage exists; frozen-oracle fuzz parity is still being added." - }, - "responses": { - "coverage": "partial", - "selectors": [ - "tests/test_litellm/responses/test_rust_bridge_websocket.py" - ], - "note": "Covers the websocket bridge; full responses parity is still being added." - }, - "count_tokens": { - "coverage": "planned", - "selectors": [], - "note": "No Rust count_tokens parity test is present yet." - }, - "chat_completions": { - "coverage": "partial", - "selectors": [ - "tests/test_litellm/rust_bridge/test_chat_completions.py" - ], - "note": "Bridge coverage exists; frozen-oracle fuzz parity is still being added." - }, - "transcription": { - "coverage": "partial", - "selectors": [ - "tests/test_litellm/test_audio_transcription_rust_bridge.py" - ], - "note": "Bridge coverage exists; frozen-oracle fuzz parity is still being added." - } - }, - "gateway": {} -} diff --git a/tests/rust-python-harness/strategies/e2e_parity/test_runner.py b/tests/rust-python-harness/strategies/e2e_parity/test_runner.py new file mode 100644 index 00000000000..31ef907465a --- /dev/null +++ b/tests/rust-python-harness/strategies/e2e_parity/test_runner.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from collections.abc import Generator +from contextlib import contextmanager +from functools import partial +from pathlib import Path +from types import SimpleNamespace +from typing import Final +from unittest.mock import Mock, call + +from pytest import MonkeyPatch + +from ...shared.reporting.models import Coverage, HarnessCase, RunStatus +from ...shared.reporting.strategy import ModuleCaseSpec +from . import runner as e2e_runner +from .runner import E2ECheck, run_e2e_cases + + +def test_runs_checks_inside_suite_context(monkeypatch: MonkeyPatch, tmp_path: Path) -> None: + lifecycle: Final = Mock() + + @contextmanager + def parity_checks() -> Generator[tuple[E2ECheck, ...]]: + lifecycle("entered") + try: + yield (E2ECheck("check", partial(lifecycle, "checked")),) + finally: + lifecycle("exited") + + module: Final = SimpleNamespace(parity_checks=parity_checks) + + def import_module(_name: str, _package: str | None = None) -> SimpleNamespace: + return module + + monkeypatch.setattr(e2e_runner.importlib, "import_module", import_module) + case: Final = HarnessCase( + strategy_id="e2e_parity", + strategy_label="End-to-end parity", + sdk_function="ocr", + spec=ModuleCaseSpec(coverage=Coverage.PARTIAL, module="example"), + surface="sdk", + ) + + code, run = run_e2e_cases((case,), tmp_path, lambda _: None) + + assert code == 0, run.failures + assert run.results[case.key].status is RunStatus.PASSED + assert lifecycle.call_args_list == [call("entered"), call("checked"), call("exited")] diff --git a/tests/rust-python-harness/strategies/existing_e2e_test_sdk/README.md b/tests/rust-python-harness/strategies/existing_e2e_test_sdk/README.md deleted file mode 100644 index fb84f170703..00000000000 --- a/tests/rust-python-harness/strategies/existing_e2e_test_sdk/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Existing e2e SDK tests - -Wires already-existing live-API SDK tests into the matrix instead of writing new parity tests. Selectors point at real test files and folders, such as `tests/ocr_tests/`, rather than individual node IDs, so future tests added to those folders are picked up automatically. diff --git a/tests/rust-python-harness/strategies/existing_e2e_test_sdk/__init__.py b/tests/rust-python-harness/strategies/existing_e2e_test_sdk/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/tests/rust-python-harness/strategies/existing_e2e_test_sdk/runner.py b/tests/rust-python-harness/strategies/existing_e2e_test_sdk/runner.py deleted file mode 100644 index f5ea17735fc..00000000000 --- a/tests/rust-python-harness/strategies/existing_e2e_test_sdk/runner.py +++ /dev/null @@ -1,26 +0,0 @@ -from __future__ import annotations - -from collections.abc import Sequence -from pathlib import Path - -from ...shared.reporting.models import HarnessCase, HarnessRun -from ...shared.reporting.pytest_runner import UpdateCallback, run_pytest - - -def run( - cases: Sequence[HarnessCase], - repo_root: Path, - on_update: UpdateCallback, - pytest_args: Sequence[str] = (), -) -> tuple[int, HarnessRun]: - return run_pytest(cases, repo_root, on_update, pytest_args) - - -def main(argv: Sequence[str] | None = None) -> int: - from ...cli import main as harness_main - - return harness_main(argv, strategy_id="existing_e2e_test_sdk") - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tests/rust-python-harness/strategies/existing_e2e_test_sdk/strategy.json b/tests/rust-python-harness/strategies/existing_e2e_test_sdk/strategy.json deleted file mode 100644 index eefceea1a75..00000000000 --- a/tests/rust-python-harness/strategies/existing_e2e_test_sdk/strategy.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "order": 40, - "id": "existing_e2e_test_sdk", - "label": "Existing e2e SDK tests", - "description": "Wire already-existing live-API SDK tests into the matrix instead of writing new parity tests.", - "functions": { - "ocr": {"coverage": "partial", "selectors": ["tests/ocr_tests/"], "note": "Existing live OCR provider tests; not yet a frozen Rust/Python oracle comparison."}, - "messages": {"coverage": "planned", "selectors": []}, - "responses": {"coverage": "planned", "selectors": []}, - "count_tokens": {"coverage": "planned", "selectors": []}, - "chat_completions": {"coverage": "partial", "selectors": ["tests/llm_translation/test_anthropic_completion.py", "tests/llm_translation/test_bedrock_completion.py"], "note": "Existing live chat completion tests for providers with confirmed Rust bridge regressions."}, - "transcription": {"coverage": "partial", "selectors": ["tests/audio_tests/test_whisper.py"], "note": "Existing live Whisper transcription test."} - } -} diff --git a/tests/rust-python-harness/strategies/trace_parity/AGENTS.md b/tests/rust-python-harness/strategies/trace_parity/AGENTS.md new file mode 100644 index 00000000000..bb7cb8c91d8 --- /dev/null +++ b/tests/rust-python-harness/strategies/trace_parity/AGENTS.md @@ -0,0 +1 @@ +Maps Python profiler frames onto feature-gated Rust span names via an explicit per-case mapping (Rust span name is the identity) and compares steps, order, and nesting of both live traces against a replayed provider response. diff --git a/tests/rust-python-harness/strategies/trace_parity/README.md b/tests/rust-python-harness/strategies/trace_parity/README.md deleted file mode 100644 index 6520a510112..00000000000 --- a/tests/rust-python-harness/strategies/trace_parity/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# Trace Parity - -Run independently with `uv run python -m tests.rust-python-harness.strategies.trace_parity.runner --plain`. Configure SDK and gateway selectors in `strategy.json`; keep API-specific execution and fixtures in their owning surface folder - -See [the harness guide](../../README.md) for coverage status and shared comparison tools diff --git a/tests/rust-python-harness/strategies/trace_parity/__init__.py b/tests/rust-python-harness/strategies/trace_parity/__init__.py index e69de29bb2d..ec88b0169fa 100644 --- a/tests/rust-python-harness/strategies/trace_parity/__init__.py +++ b/tests/rust-python-harness/strategies/trace_parity/__init__.py @@ -0,0 +1,115 @@ +from pathlib import Path +from typing import Final + +from ...shared.reporting.models import SURFACES, Coverage +from ...shared.reporting.strategy import ( + CaseDefinition, + ModuleCaseSpec, + NotImplementedCaseSpec, + RunnerArgumentDefinition, + StrategyDefinition, +) +from .reporting import render_trace_results +from .runner import run_trace_cases + +CASES: Final[tuple[CaseDefinition, ...]] = ( + CaseDefinition( + "ocr", + ModuleCaseSpec( + coverage=Coverage.PARTIAL, + module="tests.rust-python-harness.strategies.trace_parity.sdk.ocr.case", + ), + surface="sdk", + ), + CaseDefinition( + "messages", + ModuleCaseSpec( + coverage=Coverage.PARTIAL, + module="tests.rust-python-harness.strategies.trace_parity.sdk.messages.case", + note="Async only until anthropic_messages_handler supports sync calls.", + ), + surface="sdk", + ), + CaseDefinition( + "responses", + NotImplementedCaseSpec(reason="No Responses trace-parity case is registered."), + surface="sdk", + ), + CaseDefinition( + "count_tokens", + NotImplementedCaseSpec(reason="No token-count trace-parity case is registered."), + surface="sdk", + ), + CaseDefinition( + "chat_completions", + ModuleCaseSpec( + coverage=Coverage.PARTIAL, + module="tests.rust-python-harness.strategies.trace_parity.sdk.chat_completions.case", + ), + surface="sdk", + ), + CaseDefinition( + "transcription", + ModuleCaseSpec( + coverage=Coverage.PARTIAL, + module="tests.rust-python-harness.strategies.trace_parity.sdk.transcription.case", + note=( + "The Python SDK delegates this provider to the Rust pipeline, so only dispatch is visible " + "to the Python profiler." + ), + ), + surface="sdk", + ), + CaseDefinition( + "ocr", + NotImplementedCaseSpec(reason="No gateway OCR trace-parity case is registered."), + surface="gateway", + ), + CaseDefinition( + "messages", + ModuleCaseSpec( + coverage=Coverage.PARTIAL, + module="tests.rust-python-harness.strategies.trace_parity.gateway.messages.case", + note="Non-streaming success paths only.", + ), + surface="gateway", + ), + CaseDefinition( + "responses", + NotImplementedCaseSpec(reason="No gateway Responses trace-parity case is registered."), + surface="gateway", + ), + CaseDefinition( + "count_tokens", + NotImplementedCaseSpec(reason="No gateway token-count trace-parity case is registered."), + surface="gateway", + ), + CaseDefinition( + "chat_completions", + NotImplementedCaseSpec(reason="No gateway chat trace-parity case is registered."), + surface="gateway", + ), + CaseDefinition( + "transcription", + NotImplementedCaseSpec(reason="No gateway transcription trace-parity case is registered."), + surface="gateway", + ), +) + +STRATEGY: Final = StrategyDefinition( + id="trace_parity", + order=20, + label="Trace parity", + description="Compare pipeline steps, order, and nesting between Python profiler frames and Rust spans via an explicit mapping.", + directory=Path(__file__).parent, + runnable_spec=ModuleCaseSpec, + cases=CASES, + run=run_trace_cases, + render=render_trace_results, + surfaces=SURFACES, + runner_argument=RunnerArgumentDefinition( + option="--scenario", + metavar="NAME", + help="run only this named trace scenario; repeat to select more than one", + ), +) diff --git a/tests/rust-python-harness/strategies/trace_parity/gateway/__init__.py b/tests/rust-python-harness/strategies/trace_parity/gateway/__init__.py index e69de29bb2d..f999dfecfc6 100644 --- a/tests/rust-python-harness/strategies/trace_parity/gateway/__init__.py +++ b/tests/rust-python-harness/strategies/trace_parity/gateway/__init__.py @@ -0,0 +1 @@ +"""In-process gateway trace adapters.""" diff --git a/tests/rust-python-harness/strategies/trace_parity/gateway/execution.py b/tests/rust-python-harness/strategies/trace_parity/gateway/execution.py new file mode 100644 index 00000000000..2bd3a50f39f --- /dev/null +++ b/tests/rust-python-harness/strategies/trace_parity/gateway/execution.py @@ -0,0 +1,160 @@ +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable +from pathlib import Path +from typing import Final, Protocol, cast + +import httpx +from pydantic import BaseModel, ConfigDict + +from ....shared.parity.replay import replay_server +from ....shared.tracing.native import TraceResponsePayload, native_trace_events +from ....shared.tracing.profiler import FunctionTraceEvent, profile_python +from ....shared.tracing.steps import Engine, PipelineProjection, pipeline_projection +from ..models import GatewayRouteSpec, RouteFixture, TraceExecutionFailure, TraceMode, TraceScenario +from ..reporting import TraceComparisonArtifact + + +class _GatewayResponsePayload(BaseModel): + model_config = ConfigDict(strict=True, extra="forbid") + + status: int + body: object + + +class _GatewayClient(Protocol): + def post(self, url: str, *, json: object, headers: dict[str, str]) -> httpx.Response: ... + + +def _collect_python(fixture: RouteFixture) -> tuple[FunctionTraceEvent, ...]: + import litellm + from fastapi.testclient import TestClient + + from litellm.proxy._types import UserAPIKeyAuth + from litellm.proxy.anthropic_endpoints.endpoints import user_api_key_auth + from litellm.proxy import proxy_server + + provider_model: Final = cast(str, fixture.kwargs["provider_model"]) + model_alias: Final = cast(str, fixture.kwargs["model_alias"]) + old_router: Final = proxy_server.llm_router + old_override: Final = proxy_server.app.dependency_overrides.get(user_api_key_auth) + + async def authorize() -> UserAPIKeyAuth: + return UserAPIKeyAuth(api_key="trace-key") + + proxy_server.llm_router = litellm.Router( + model_list=[ + { + "model_name": model_alias, + "litellm_params": { + "model": provider_model, + "api_key": "trace-provider-key", + "api_base": fixture.kwargs["api_base"], + }, + } + ] + ) + proxy_server.app.dependency_overrides[user_api_key_auth] = authorize + try: + with profile_python(Path(litellm.__file__).parent, threads=True) as profiler: + client: Final = cast(_GatewayClient, TestClient(proxy_server.app)) + response: Final = client.post( + "/v1/messages", + json=fixture.kwargs["body"], + headers={"authorization": "Bearer trace-key"}, + ) + if response.status_code != 200: + raise RuntimeError(f"Python gateway returned {response.status_code}: {response.text}") + return tuple(profiler.events) + finally: + proxy_server.llm_router = old_router + if old_override is None: + proxy_server.app.dependency_overrides.pop(user_api_key_auth, None) + else: + proxy_server.app.dependency_overrides[user_api_key_auth] = old_override + + +def _collect_rust(fixture: RouteFixture) -> tuple[FunctionTraceEvent, ...]: + from litellm.rust_bridge import get_native_bridge + + bridge: Final[object | None] = get_native_bridge() + trace: Final[object | None] = getattr(bridge, "_trace", None) if bridge is not None else None + gateway_messages: Final[object | None] = getattr(trace, "gateway_messages", None) + if gateway_messages is None or not callable(gateway_messages): + raise RuntimeError("native Rust trace bridge does not expose gateway_messages") + invoke_gateway: Final = cast(Callable[[str, str, str, object], Awaitable[object]], gateway_messages) + + async def invoke() -> object: + return await invoke_gateway( + cast(str, fixture.kwargs["model_alias"]), + cast(str, fixture.kwargs["provider_model"]), + cast(str, fixture.kwargs["api_base"]), + fixture.kwargs["body"], + ) + + result: Final = asyncio.run(invoke()) + payload: Final = TraceResponsePayload.model_validate(result) + response: Final = _GatewayResponsePayload.model_validate(payload.response) + if response.status != 200: + raise RuntimeError(f"Rust gateway returned {response.status}: {response.body}") + return native_trace_events(payload) + + +def _collect(scenario: TraceScenario, engine: Engine) -> tuple[FunctionTraceEvent, ...] | TraceExecutionFailure: + try: + with replay_server() as provider: + base_fixture: Final = scenario.fixture(engine, provider.url) + fixture: Final = RouteFixture( + kwargs={**base_fixture.kwargs, "api_base": provider.url}, + provider_responses=base_fixture.provider_responses, + ) + for response in fixture.provider_responses: + provider.enqueue_response(response) + events: Final = _collect_python(fixture) if engine == "python" else _collect_rust(fixture) + provider.take_requests(len(fixture.provider_responses)) + return events + except Exception as error: + return TraceExecutionFailure(engine, f"{type(error).__name__}: {error}") + + +def _projections( + python_events: tuple[FunctionTraceEvent, ...], + rust_events: tuple[FunctionTraceEvent, ...], + scenario: TraceScenario, + mode: TraceMode, +) -> tuple[PipelineProjection, PipelineProjection, str | None]: + mappings: Final = scenario.mappings_for(mode) + try: + return ( + pipeline_projection("python", python_events, mappings), + pipeline_projection("rust", rust_events, mappings), + None, + ) + except ValueError as error: + return PipelineProjection(), PipelineProjection(), f"harness: {error}" + + +def execute_gateway_trace(route: GatewayRouteSpec, scenario: TraceScenario, mode: TraceMode) -> TraceComparisonArtifact: + mappings: Final = scenario.mappings_for(mode) + python_trace: Final = _collect(scenario, "python") + rust_trace: Final = _collect(scenario, "rust") + collection_python_error: Final = None if isinstance(python_trace, tuple) else f"python: {python_trace.message}" + rust_error: Final = None if isinstance(rust_trace, tuple) else f"rust: {rust_trace.message}" + python_events: Final = python_trace if isinstance(python_trace, tuple) else () + rust_events: Final = rust_trace if isinstance(rust_trace, tuple) else () + python, rust, projection_error = _projections(python_events, rust_events, scenario, mode) + python_error: Final = projection_error or collection_python_error + return TraceComparisonArtifact.from_traces( + surface="gateway", + sdk_function=route.route, + scenario=scenario.name, + mode=mode, + mappings=mappings, + contract=scenario.contract, + python=python.steps, + rust=rust.steps, + python_unmatched=python.unmatched, + python_error=python_error, + rust_error=rust_error, + ) diff --git a/tests/rust-python-harness/strategies/trace_parity/gateway/messages/__init__.py b/tests/rust-python-harness/strategies/trace_parity/gateway/messages/__init__.py new file mode 100644 index 00000000000..bd9195b7c22 --- /dev/null +++ b/tests/rust-python-harness/strategies/trace_parity/gateway/messages/__init__.py @@ -0,0 +1 @@ +"""Messages gateway trace cases.""" diff --git a/tests/rust-python-harness/strategies/trace_parity/gateway/messages/case.py b/tests/rust-python-harness/strategies/trace_parity/gateway/messages/case.py new file mode 100644 index 00000000000..30f51cee353 --- /dev/null +++ b/tests/rust-python-harness/strategies/trace_parity/gateway/messages/case.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +import json +from typing import Final + +from .....shared.parity.recorded_http import HttpHeader, RecordedHttpResponse +from .....shared.tracing.steps import Engine, mapping +from ...models import GatewayRouteSpec, RouteFixture, TraceScenario, TraceSuite + + +GATEWAY_MAPPINGS: Final = ( + mapping( + span="python_messages_gateway_route", + python_frame=r"anthropic_endpoints/endpoints\.py:\d+ anthropic_response$", + ), + mapping(rust_span="messages_gateway_route"), + mapping( + span="python_messages_gateway_service", + python_frame=r"ProxyBaseLLMRequestProcessing\.base_process_llm_request$", + ), + mapping(rust_span="messages_gateway_service"), + mapping(rust_span="messages"), + mapping( + span="python_messages_provider_config", + python_frame=r"ProviderConfigManager\.get_provider_anthropic_messages_config$", + ), + mapping(rust_span="messages_provider_config"), + mapping(rust_span="validate_environment", python_frame=r"validate_anthropic_messages_environment$"), + mapping(rust_span="complete_url", python_frame=r"get_complete_url$"), + mapping(span="python_messages_entry_handler", python_frame=r"messages/handler\.py:\d+ anthropic_messages_handler$"), + mapping(span="python_messages_handler_wrapper", python_frame=r"BaseLLMHTTPHandler\.anthropic_messages_handler$"), + mapping( + rust_span="execute_messages_provider_call", + python_frame=r"BaseLLMHTTPHandler\.async_anthropic_messages_handler$", + ), + mapping(rust_span="http_request", python_frame=r"AsyncHTTPHandler\.post$|HTTPHandler\.post$"), + mapping(rust_span="transform_response", python_frame=r"(? RouteFixture: + return RouteFixture( + kwargs={ + "model_alias": "trace-model", + "provider_model": f"{provider}/claude-sonnet-5", + "body": { + "model": "trace-model", + "messages": [{"role": "user", "content": "hello"}], + "max_tokens": 16, + }, + }, + provider_responses=( + RecordedHttpResponse.from_bytes( + 200, + (HttpHeader(name="content-type", value="application/json"),), + json.dumps( + { + "id": "msg_trace", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-5", + "content": [{"type": "text", "text": "hello"}], + "stop_reason": "end_turn", + "stop_sequence": None, + "usage": {"input_tokens": 2, "output_tokens": 3}, + } + ).encode(), + ), + ), + ) + + +def _anthropic_fixture(engine: Engine, _base_url: str) -> RouteFixture: + return _fixture(engine, "anthropic") + + +def _azure_fixture(engine: Engine, _base_url: str) -> RouteFixture: + return _fixture(engine, "azure_ai") + + +ANTHROPIC_MAPPINGS: Final = ( + *GATEWAY_MAPPINGS, + mapping( + rust_span="transform_request", + python_frame=r"(? tuple[TraceMapping, ...]: + selected: Final = self.async_mappings if mode == "async" else self.sync_mappings + return self.mappings if selected is None else selected + + +@dataclass(frozen=True, slots=True) +class TraceSuite: + route: TraceRouteSpec + scenarios: tuple[TraceScenario, ...] + + +@dataclass(frozen=True, slots=True) +class TraceExecutionFailure: + engine: TraceFailureSource + message: str diff --git a/tests/rust-python-harness/strategies/trace_parity/reporting.py b/tests/rust-python-harness/strategies/trace_parity/reporting.py new file mode 100644 index 00000000000..9c5bf9e88cd --- /dev/null +++ b/tests/rust-python-harness/strategies/trace_parity/reporting.py @@ -0,0 +1,367 @@ +from __future__ import annotations + +import os +import re +import sys +from collections.abc import Sequence +from typing import Final, Literal + +from pydantic import BaseModel, ConfigDict, ValidationError + +from ...shared.reporting.models import SURFACES, CaseResult, RunStatus, SdkFunction, Surface +from ...shared.reporting.rendering import ReportSection +from ...shared.reporting.strategy import NotImplementedCaseSpec, SkippedCaseSpec +from ...shared.tracing.steps import ( + PipelineStep, + TraceContract, + TraceDiff, + TraceMapping, + trace_depths, + trace_diff, +) + +TRACE_COMPARISON_ARTIFACT: Final = "trace_comparison" +TRACE_PARITY_HINT: Final = ( + "rebuild the native bridge with the trace-parity feature, e.g. `uvx maturin develop --features trace-parity`" +) + +_COLORS: Final[dict[str, str]] = {"green": "32", "yellow": "33", "red": "31", "cyan": "36"} +_RESET: Final = "\033[0m" + + +def _paint(text: str, color: str) -> str: + if not sys.stdout.isatty() or os.environ.get("NO_COLOR"): + return text + return f"\033[{_COLORS[color]}m{text}{_RESET}" + + +class TraceEventArtifact(BaseModel): + model_config = ConfigDict(frozen=True, extra="forbid") + + id: int + parent_id: int | None + span: str + raw: str + + def step(self) -> PipelineStep: + return PipelineStep(self.id, self.parent_id, self.span, self.raw) + + +class TraceMappingArtifact(BaseModel): + model_config = ConfigDict(frozen=True, extra="forbid") + + span: str + python: str | None + rust: str | None + + +class TraceComparisonArtifact(BaseModel): + model_config = ConfigDict(frozen=True, extra="forbid") + + surface: Surface + sdk_function: SdkFunction + scenario: str + mode: Literal["sync", "async"] + mappings: tuple[TraceMappingArtifact, ...] + python: tuple[TraceEventArtifact, ...] + rust: tuple[TraceEventArtifact, ...] + python_unmatched: int + unordered_children_of: frozenset[str] + python_error: str | None = None + rust_error: str | None = None + + @classmethod + def from_traces( + cls, + *, + surface: Surface, + sdk_function: SdkFunction, + scenario: str, + mode: Literal["sync", "async"], + mappings: Sequence[TraceMapping], + contract: TraceContract, + python: Sequence[PipelineStep], + rust: Sequence[PipelineStep], + python_unmatched: int, + python_error: str | None = None, + rust_error: str | None = None, + ) -> TraceComparisonArtifact: + return cls( + surface=surface, + sdk_function=sdk_function, + scenario=scenario, + mode=mode, + mappings=tuple( + TraceMappingArtifact( + span=item.span, + python=item.python.pattern if item.python else None, + rust=item.rust, + ) + for item in mappings + ), + python=tuple( + TraceEventArtifact(id=step.id, parent_id=step.parent_id, span=step.span, raw=step.raw) + for step in python + ), + rust=tuple( + TraceEventArtifact(id=step.id, parent_id=step.parent_id, span=step.span, raw=step.raw) + for step in rust + ), + python_unmatched=python_unmatched, + unordered_children_of=contract.unordered_children_of, + python_error=python_error, + rust_error=rust_error, + ) + + def python_steps(self) -> tuple[PipelineStep, ...]: + return tuple(event.step() for event in self.python) + + def rust_steps(self) -> tuple[PipelineStep, ...]: + return tuple(event.step() for event in self.rust) + + def diff(self) -> TraceDiff: + return trace_diff( + self.python_steps(), + self.rust_steps(), + tuple( + TraceMapping( + item.span, + re.compile(item.python) if item.python is not None else None, + item.rust, + ) + for item in self.mappings + ), + TraceContract(self.unordered_children_of), + ) + + def exact_match(self) -> bool: + return self.diff().matches + + def has_errors(self) -> bool: + return self.python_error is not None or self.rust_error is not None + + def contract_matches(self) -> bool: + if self.has_errors(): + return False + return self.diff().matches + + +def _split_raw(raw: str) -> tuple[str, str]: + location, separator, name = raw.partition(" ") + if separator: + return name, location + return raw, "" + + +def _python_line(index: int, step: PipelineStep, depth: int, exclusive: frozenset[str]) -> str: + name: Final = _split_raw(step.raw)[0] + location: Final = _split_raw(step.raw)[1] + suffix: Final = f" ({location})" if location else "" + marker: Final = " [python only]" if step.span in exclusive else "" + return _paint(f"{index} {' ' * depth}{name}{suffix}{marker}", "cyan") + + +def _python_lines(steps: tuple[PipelineStep, ...], exclusive: frozenset[str]) -> str: + depths: Final = trace_depths(steps) + lines: Final = tuple( + _python_line(index, step, depths[step.id], exclusive) for index, step in enumerate(steps, start=1) + ) + return f"{_paint('PYTHON', 'cyan')} ({len(steps)} steps)\n" + ("\n".join(lines) if lines else "(empty)") + + +def _python_references(steps: tuple[PipelineStep, ...]) -> dict[tuple[str, int], str]: + references: dict[tuple[str, int], str] = {} + occurrences: dict[str, int] = {} + for index, step in enumerate(steps, start=1): + name = _split_raw(step.raw)[0] + occurrence = occurrences.get(step.span, 0) + 1 + occurrences[step.span] = occurrence + references[(step.span, occurrence)] = f"{index} {name}" + return references + + +def _rust_line( + step: PipelineStep, + depth: int, + occurrence: int, + references: dict[tuple[str, int], str], +) -> str: + span: Final = _paint(step.span, "yellow") + key: Final = (step.span, occurrence) + reference: Final = ( + _paint(references[key], "cyan") if key in references else _paint("[rust only]", "yellow") + ) + suffix: Final = f"#{occurrence}" if occurrence > 1 else "" + return f"{' ' * depth}{span}{suffix} -> {reference}" + + +def _rust_lines(steps: tuple[PipelineStep, ...], references: dict[tuple[str, int], str]) -> str: + depths: Final = trace_depths(steps) + occurrences: dict[str, int] = {} + lines: list[str] = [] + for step in steps: + occurrence = occurrences.get(step.span, 0) + 1 + occurrences[step.span] = occurrence + lines.append(_rust_line(step, depths[step.id], occurrence, references)) + return f"{_paint('RUST', 'yellow')} ({len(steps)} steps)\n" + ("\n".join(lines) if lines else "(empty)") + + +def _state_text(state: str, *, good: bool) -> str: + return _paint(state, "green" if good else "red") + + +def _contract_line(artifact: TraceComparisonArtifact) -> str: + matches: Final = artifact.contract_matches() + status: Final = _state_text("PASS" if matches else "FAIL", good=matches) + if artifact.python_error or artifact.rust_error: + return f"Contract: {status}" + return f"Contract: {status}" + + +def _error_lines(artifact: TraceComparisonArtifact) -> tuple[str, ...]: + lines: list[str] = [] + for engine, error in (("Python", artifact.python_error), ("Rust", artifact.rust_error)): + if error is None: + continue + lines.append(_paint(f"{engine} error: {error}", "red")) + if "trace-parity feature" in error: + lines.append(f"hint: {TRACE_PARITY_HINT}") + return tuple(lines) + + +def _unseen_mappings( + artifact: TraceComparisonArtifact, + python: tuple[PipelineStep, ...], + rust: tuple[PipelineStep, ...], +) -> tuple[str, ...]: + return artifact.diff().missing_mappings + + +def _comparison_status_lines( + artifact: TraceComparisonArtifact, + python: tuple[PipelineStep, ...], + rust: tuple[PipelineStep, ...], +) -> tuple[str, ...]: + diff: Final = artifact.diff() + exact_match: Final = artifact.exact_match() + if artifact.has_errors(): + return (*_error_lines(artifact), _contract_line(artifact)) + unseen: Final = _unseen_mappings(artifact, python, rust) + unseen_line: Final[tuple[str, ...]] = (f"Unseen mappings: {', '.join(unseen)}",) if unseen else () + drift_lines: Final[tuple[str, ...]] = ( + (_state_text("Same steps, order, and nesting", good=True),) + if exact_match + else ( + _paint(f"Python only: {', '.join(diff.python_only) or 'none'}", "cyan"), + _paint(f"Rust only: {', '.join(diff.rust_only) or 'none'}", "yellow"), + f"First difference: {diff.first_difference or 'none'}", + f"Python frames outside mapping: {artifact.python_unmatched}", + ) + ) + return ( + f"Trace: {_state_text('MATCH' if exact_match else 'DRIFT', good=exact_match)}", + *drift_lines, + *unseen_line, + _contract_line(artifact), + ) + + +def _render_comparison(artifact: TraceComparisonArtifact) -> str: + python: Final = artifact.python_steps() + rust: Final = artifact.rust_steps() + diff: Final = artifact.diff() + python_exclusive: Final = frozenset(item.span for item in artifact.mappings if item.rust is None) + status_lines: Final = _comparison_status_lines(artifact, python, rust) + return "\n\n".join( + ( + _python_lines(python, python_exclusive | frozenset(diff.python_only)), + _rust_lines(rust, _python_references(python)), + "\n".join(status_lines), + ) + ) + + +def _mode(nodeid: str) -> str: + if "[" in nodeid: + return nodeid.rsplit("[", 1)[-1].removesuffix("]") + head, _, tail = nodeid.rpartition(":") + return tail if head else "unknown mode" + + +def _scenario(nodeid: str) -> str: + parts: Final = nodeid.split(":") + return parts[-2] if len(parts) >= 5 else "default" + + +def _unavailable(status: RunStatus) -> str: + return f"Trace: NOT AVAILABLE\nTest outcome: {status.value}" + + +def _render_artifact(body: str) -> str: + try: + artifact: Final = TraceComparisonArtifact.model_validate_json(body) + except ValidationError as error: + return f"Trace comparison artifact is invalid: {error}" + return _render_comparison(artifact) + + +def _mode_section(result: CaseResult, nodeid: str, status: RunStatus) -> str: + artifacts: Final = tuple( + artifact for artifact in result.artifacts.get(nodeid, ()) if artifact.kind == TRACE_COMPARISON_ARTIFACT + ) + body: Final = ( + "\n\n".join(_render_artifact(artifact.body) for artifact in artifacts) if artifacts else _unavailable(status) + ) + label: Final = f"Scenario: {_scenario(nodeid)} / Mode: {_mode(nodeid)}" + return f"{label}\n{'-' * len(label)}\n\n{body}" + + +def _case_block(result: CaseResult) -> str: + header: Final = f"Case: {result.case.sdk_function}" + outcomes: Final = tuple(result.outcomes.items()) or ( + (nodeid, RunStatus.NOT_RUN) for nodeid in sorted(result.collected) + ) + sections: Final = tuple(_mode_section(result, nodeid, status) for nodeid, status in outcomes) + return "\n\n".join((f"{header}\n{'=' * len(header)}", *sections)) + + +def _unavailable_block(title: str, lines: tuple[str, ...]) -> str | None: + if not lines: + return None + return f"{title}\n{'-' * len(title)}\n" + "\n".join(lines) + + +def _surface_section(surface: Surface, results: Sequence[CaseResult]) -> ReportSection | None: + selected: Final = tuple(result for result in results if result.case.surface == surface) + if not selected: + return None + outcome_blocks: Final = tuple(_case_block(result) for result in selected if result.outcomes) + not_implemented: Final = _unavailable_block( + "Not implemented", + tuple( + f"- {result.case.sdk_function}: {spec.reason}" + for result in selected + if isinstance((spec := result.case.spec), NotImplementedCaseSpec) + ), + ) + skipped: Final = _unavailable_block( + "Skipped", + tuple( + f"- {result.case.sdk_function}: {spec.reason}" + for result in selected + if isinstance((spec := result.case.spec), SkippedCaseSpec) + ), + ) + blocks: Final = ( + *outcome_blocks, + *((not_implemented,) if not_implemented else ()), + *((skipped,) if skipped else ()), + ) + return ReportSection(f"{surface.upper()} trace comparisons", blocks or ("No runnable trace comparisons",)) + + +def render_trace_results(results: Sequence[CaseResult]) -> tuple[ReportSection, ...]: + sections: Final = tuple( + section for surface in SURFACES if (section := _surface_section(surface, results)) is not None + ) + return sections or (ReportSection("Trace comparisons", ("No trace comparisons selected",)),) diff --git a/tests/rust-python-harness/strategies/trace_parity/runner.py b/tests/rust-python-harness/strategies/trace_parity/runner.py index 127bf5dce40..ef373a6f2f6 100644 --- a/tests/rust-python-harness/strategies/trace_parity/runner.py +++ b/tests/rust-python-harness/strategies/trace_parity/runner.py @@ -1,26 +1,182 @@ from __future__ import annotations +import importlib from collections.abc import Sequence from pathlib import Path +from time import monotonic +from typing import Final -from ...shared.reporting.models import HarnessCase, HarnessRun -from ...shared.reporting.pytest_runner import UpdateCallback, run_pytest +from ...shared.reporting.models import CaseResult, HarnessCase, HarnessRun, ResultArtifact, RunStatus, Surface +from ...shared.reporting.strategy import ModuleCaseSpec, UpdateCallback +from ...shared.native_build import ensure_trace_bridge +from .models import GatewayRouteSpec, RouteSpec, TraceExecutionFailure, TraceMode, TraceScenario, TraceSuite +from .reporting import TRACE_COMPARISON_ARTIFACT, TraceComparisonArtifact +from .sdk.execution import execute_trace -def run( +def _load_case(reference: str, harness_case: HarnessCase) -> TraceSuite | TraceExecutionFailure: + try: + module: Final = importlib.import_module(reference) + except Exception as error: + return TraceExecutionFailure("harness", f"cannot import {reference}: {type(error).__name__}: {error}") + suite: Final = getattr(module, "TRACE_SUITE", None) + if not isinstance(suite, TraceSuite): + return TraceExecutionFailure("harness", f"{reference} must export TRACE_SUITE: TraceSuite") + validation_error: Final = validate_trace_suite(suite, harness_case) + if validation_error is not None: + return TraceExecutionFailure("harness", f"{reference} {validation_error}") + return suite + + +def validate_trace_suite(suite: TraceSuite, harness_case: HarnessCase) -> str | None: + names: Final = tuple(scenario.name for scenario in suite.scenarios) + if not names or len(names) != len(set(names)) or any(not name or ":" in name for name in names): + return "scenario names must be non-empty, unique, and colon-free" + invalid_modes: Final = tuple( + scenario.name + for scenario in suite.scenarios + if not scenario.modes + or len(scenario.modes) != len(set(scenario.modes)) + or any(mode not in {"sync", "async"} for mode in scenario.modes) + ) + if invalid_modes: + return f"scenarios must use non-empty, unique sync/async modes: {', '.join(invalid_modes)}" + surface: Final = harness_case.surface + if surface == "sdk" and not isinstance(suite.route, RouteSpec): + return "must use RouteSpec for the sdk surface" + if surface == "gateway" and not isinstance(suite.route, GatewayRouteSpec): + return "must use GatewayRouteSpec for the gateway surface" + if surface is None: + return "requires an sdk or gateway surface" + if suite.route.route != harness_case.sdk_function: + return f"route {suite.route.route} does not match case function {harness_case.sdk_function}" + return None + + +def scenario_nodeids( + trace_suite: TraceSuite, + harness_case: HarnessCase, + selected_scenarios: frozenset[str] = frozenset(), +) -> tuple[tuple[TraceScenario, TraceMode, str], ...]: + surface: Final = harness_case.surface + if surface is None: + return () + return tuple( + (scenario, mode, f"trace:{surface}:{harness_case.sdk_function}:{scenario.name}:{mode}") + for scenario in trace_suite.scenarios + if not selected_scenarios or scenario.name in selected_scenarios + for mode in scenario.modes + ) + + +def _record_setup_failure(run: HarnessRun, case: HarnessCase, message: str, stage: str) -> None: + result: Final = run.results[case.key] + nodeid: Final = f"trace:{case.surface}:{case.sdk_function}:{stage}" + result.collected.add(nodeid) + result.record(nodeid, RunStatus.ERROR) + run.failures.append((nodeid, message)) + + +def run_trace_mode( + run: HarnessRun, + result: CaseResult, + trace_suite: TraceSuite, + scenario: TraceScenario, + mode: TraceMode, + surface: Surface, + nodeid: str, + on_update: UpdateCallback, +) -> None: + started_at: Final = monotonic() + comparison: Final = _execute_mode(trace_suite, scenario, mode, surface) + duration: Final = monotonic() - started_at + if isinstance(comparison, TraceExecutionFailure): + result.record(nodeid, RunStatus.ERROR, duration) + run.failures.append((nodeid, comparison.message)) + on_update(run) + return + artifact: Final = ResultArtifact(TRACE_COMPARISON_ARTIFACT, comparison.model_dump_json()) + if comparison.has_errors(): + result.record(nodeid, RunStatus.ERROR, duration, (artifact,)) + run.failures.append( + (nodeid, "\n".join(error for error in (comparison.python_error, comparison.rust_error) if error)) + ) + else: + status: Final = RunStatus.PASSED if comparison.contract_matches() else RunStatus.FAILED + result.record(nodeid, status, duration, (artifact,)) + if status is RunStatus.FAILED: + run.failures.append((nodeid, "trace contract mismatch; see the rendered comparison")) + on_update(run) + + +def _execute_mode( + trace_suite: TraceSuite, + scenario: TraceScenario, + mode: TraceMode, + surface: Surface, +) -> TraceComparisonArtifact | TraceExecutionFailure: + route: Final = trace_suite.route + if isinstance(route, GatewayRouteSpec): + if surface != "gateway": + return TraceExecutionFailure("harness", "gateway route cannot run on the sdk surface") + from .gateway.execution import execute_gateway_trace + + return execute_gateway_trace(route, scenario, mode) + if surface != "sdk": + return TraceExecutionFailure("harness", "sdk route cannot run on the gateway surface") + return execute_trace(route, scenario, mode, surface) + + +def _run_case( + run: HarnessRun, + harness_case: HarnessCase, + selected_scenarios: frozenset[str], + on_update: UpdateCallback, +) -> None: + result: Final = run.results[harness_case.key] + spec: Final = harness_case.spec + if not isinstance(spec, ModuleCaseSpec): + return + surface: Final = harness_case.surface + if surface is None: + return + trace_suite: Final = _load_case(spec.module, harness_case) + if isinstance(trace_suite, TraceExecutionFailure): + _record_setup_failure(run, harness_case, trace_suite.message, "load") + on_update(run) + return + nodeids: Final = scenario_nodeids(trace_suite, harness_case, selected_scenarios) + result.collected.update(nodeid for _, _, nodeid in nodeids) + if not nodeids: + result.status = RunStatus.SKIPPED + on_update(run) + return + result.status = RunStatus.RUNNING + on_update(run) + for scenario, mode, nodeid in nodeids: + run_trace_mode(run, result, trace_suite, scenario, mode, surface, nodeid, on_update) + + +def run_trace_cases( cases: Sequence[HarnessCase], repo_root: Path, on_update: UpdateCallback, - pytest_args: Sequence[str] = (), + runner_args: Sequence[str] = (), ) -> tuple[int, HarnessRun]: - return run_pytest(cases, repo_root, on_update, pytest_args) - - -def main(argv: Sequence[str] | None = None) -> int: - from ...cli import main as harness_main - - return harness_main(argv, strategy_id="trace_parity") - - -if __name__ == "__main__": - raise SystemExit(main()) + selected_scenarios: Final = frozenset(runner_args) + run: Final = HarnessRun.from_cases(cases) + bridge_error: Final = ensure_trace_bridge(repo_root) + if bridge_error is not None: + for harness_case in cases: + _record_setup_failure(run, harness_case, bridge_error, "bridge") + run.finished_at = monotonic() + on_update(run) + return 1, run + for harness_case in cases: + _run_case(run, harness_case, selected_scenarios, on_update) + run.finished_at = monotonic() + on_update(run) + failed: Final = any( + result.status in {RunStatus.ERROR, RunStatus.FAILED, RunStatus.MISSING} for result in run.results.values() + ) + return int(failed), run diff --git a/tests/rust-python-harness/strategies/trace_parity/sdk/__init__.py b/tests/rust-python-harness/strategies/trace_parity/sdk/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/tests/rust-python-harness/strategies/trace_parity/sdk/chat_completions/case.py b/tests/rust-python-harness/strategies/trace_parity/sdk/chat_completions/case.py new file mode 100644 index 00000000000..6be5afd60d6 --- /dev/null +++ b/tests/rust-python-harness/strategies/trace_parity/sdk/chat_completions/case.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +import json +from typing import Final + +from .....shared.parity.recorded_http import HttpHeader, RecordedHttpResponse +from .....shared.tracing.steps import Engine, mapping +from ...models import RouteFixture, RouteSpec, TraceScenario, TraceSuite + +COMMON_MAPPINGS: Final = ( + mapping(span="python_provider_config", python_frame=r"ProviderConfigManager\.get_provider_chat_config$"), + mapping(rust_span="chat_completions_provider_config"), + mapping( + span="python_supported_openai_params", + python_frame=r"litellm_core_utils/get_supported_openai_params\.py:\d+ get_supported_openai_params$", + ), + mapping( + span="python_provider_supported_openai_params", + python_frame=r"AnthropicConfig\.get_supported_openai_params$", + ), + mapping(rust_span="supported_openai_params"), + mapping(rust_span="validate_environment", python_frame=r"(? RouteFixture: + response: Final = json.dumps( + { + "id": "msg_trace", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-5", + "content": [{"type": "text", "text": "hello"}], + "stop_reason": "end_turn", + "stop_sequence": None, + "usage": {"input_tokens": 2, "output_tokens": 3}, + } + ).encode() + return RouteFixture( + kwargs={ + "model": "anthropic/claude-sonnet-5", + "messages": [{"role": "user", "content": "hello"}], + **({"optional_params": {"max_tokens": 16}} if engine == "rust" else {"max_tokens": 16}), + }, + provider_responses=( + RecordedHttpResponse.from_bytes( + 200, (HttpHeader(name="content-type", value="application/json"),), response + ), + ), + ) + + +def _bedrock_fixture(engine: Engine, _base_url: str) -> RouteFixture: + response: Final = json.dumps( + { + "output": {"message": {"role": "assistant", "content": [{"text": "hello"}]}}, + "stopReason": "end_turn", + "usage": {"inputTokens": 2, "outputTokens": 3, "totalTokens": 5}, + "metrics": {"latencyMs": 1}, + } + ).encode() + credentials: Final = { + "aws_access_key_id": "test-access", + "aws_secret_access_key": "test-secret", + "aws_region_name": "us-east-1", + } + return RouteFixture( + kwargs={ + "model": "bedrock/us-east-1/anthropic.claude-v2", + "messages": [{"role": "user", "content": "hello"}], + **( + {"optional_params": {**credentials, "maxTokens": 16}} + if engine == "rust" + else {**credentials, "max_tokens": 16} + ), + }, + provider_responses=( + RecordedHttpResponse.from_bytes( + 200, (HttpHeader(name="content-type", value="application/json"),), response + ), + ), + ) + + +SPEC: Final = RouteSpec( + "chat_completions", + ("completion", "acompletion"), + ("chat_completions", "achat_completions"), + _anthropic_fixture, +) +BEDROCK_COMMON_MAPPINGS: Final = ( + mapping(rust_span="chat_completions_provider_config"), + mapping(rust_span="supported_openai_params"), + mapping(rust_span="execute_chat_completions_provider_call"), + mapping(rust_span="validate_environment"), + mapping(rust_span="http_request", python_frame=r"AsyncHTTPHandler\.post$|HTTPHandler\.post$"), + mapping(span="python_transform_response", python_frame=r"AmazonConverseConfig\._transform_response$"), +) +BEDROCK_SYNC_MAPPINGS: Final = ( + mapping(span="python_chat_completions", python_frame=r"main\.py:\d+ completion$"), + mapping(rust_span="chat_completions"), + mapping(span="python_transform_request", python_frame=r"AmazonConverseConfig\._transform_request$"), + *BEDROCK_COMMON_MAPPINGS, +) +BEDROCK_ASYNC_MAPPINGS: Final = ( + mapping(span="python_chat_completions", python_frame=r"main\.py:\d+ acompletion$"), + mapping(span="python_completion_wrapper", python_frame=r"main\.py:\d+ completion$"), + mapping(rust_span="chat_completions"), + *BEDROCK_COMMON_MAPPINGS, +) +TRACE_SUITE: Final = TraceSuite( + route=SPEC, + scenarios=( + TraceScenario( + name="anthropic", + fixture=_anthropic_fixture, + mappings=COMMON_MAPPINGS, + sync_mappings=SYNC_MAPPINGS, + async_mappings=ASYNC_MAPPINGS, + ), + TraceScenario( + name="bedrock", + fixture=_bedrock_fixture, + mappings=BEDROCK_COMMON_MAPPINGS, + sync_mappings=BEDROCK_SYNC_MAPPINGS, + async_mappings=BEDROCK_ASYNC_MAPPINGS, + ), + ), +) diff --git a/tests/rust-python-harness/strategies/trace_parity/sdk/execution.py b/tests/rust-python-harness/strategies/trace_parity/sdk/execution.py new file mode 100644 index 00000000000..f8d7c55d4e2 --- /dev/null +++ b/tests/rust-python-harness/strategies/trace_parity/sdk/execution.py @@ -0,0 +1,140 @@ +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable +from pathlib import Path +from typing import Final, Protocol, cast + +from ....shared.parity.replay import replay_server +from ....shared.reporting.models import Surface +from ....shared.tracing.native import native_trace_events +from ....shared.tracing.profiler import FunctionTraceEvent, profile_python +from ....shared.tracing.steps import Engine, pipeline_projection +from ..models import RouteSpec, TraceExecutionFailure, TraceMode, TraceScenario +from ..reporting import TraceComparisonArtifact + + +class SdkCall(Protocol): + def __call__(self, **kwargs: object) -> object: ... + + +def _invoke(function: SdkCall, kwargs: dict[str, object], *, asynchronous: bool) -> object: + async def invoke_async() -> object: + return await cast(Awaitable[object], function(**kwargs)) + + if asynchronous: + return asyncio.run(invoke_async()) + return function(**kwargs) + + +def _entrypoint(spec: RouteSpec, engine: Engine, *, asynchronous: bool) -> SdkCall | TraceExecutionFailure: + import litellm + from litellm.anthropic_interface import messages as sdk_messages + from litellm.rust_bridge import get_native_bridge + + if engine == "rust": + bridge: Final = cast(object | None, get_native_bridge()) + if bridge is None: + return TraceExecutionFailure("rust", "native Rust bridge is required for trace parity") + trace_bridge: Final[object | None] = getattr(bridge, "_trace", None) + if trace_bridge is None: + return TraceExecutionFailure("rust", "native Rust bridge must include the trace-parity feature") + entrypoint: Final = spec.rust_entrypoints[int(asynchronous)] + function: Final[object | None] = getattr(trace_bridge, entrypoint, None) + if function is None: + return TraceExecutionFailure("rust", f"native Rust trace bridge does not expose {entrypoint}") + return cast(SdkCall, function) + owner: Final = sdk_messages if spec.route == "messages" else litellm + return cast(SdkCall, getattr(owner, spec.python_entrypoints[int(asynchronous)])) + + +def _collect( + function: SdkCall, kwargs: dict[str, object], engine: Engine, *, asynchronous: bool +) -> tuple[FunctionTraceEvent, ...]: + if engine == "rust": + return native_trace_events(_invoke(function, kwargs, asynchronous=asynchronous)) + import litellm + + with profile_python(Path(litellm.__file__).parent, threads=True) as profiler: + _invoke(function, kwargs, asynchronous=asynchronous) + return tuple(profiler.events) + + +def collect_trace( + spec: RouteSpec, engine: Engine, *, asynchronous: bool +) -> tuple[FunctionTraceEvent, ...] | TraceExecutionFailure: + function: Final = _entrypoint(spec, engine, asynchronous=asynchronous) + if isinstance(function, TraceExecutionFailure): + return function + try: + with replay_server() as provider: + fixture: Final = spec.fixture(engine, provider.url) + for response in fixture.provider_responses: + provider.enqueue_response(response) + kwargs: Final = { + **fixture.kwargs, + "api_key": "test-key", + "api_base": provider.url, + **({"timeout_seconds": 5} if engine == "rust" else {"timeout": 5}), + } + events: Final = _collect(function, kwargs, engine, asynchronous=asynchronous) + provider.take_requests(len(fixture.provider_responses)) + except Exception as error: + return TraceExecutionFailure(engine, f"{type(error).__name__}: {error}") + if not events: + return TraceExecutionFailure(engine, "trace is empty") + return events + + +def _failure_message(result: tuple[FunctionTraceEvent, ...] | TraceExecutionFailure) -> str | None: + if isinstance(result, tuple): + return None + return f"{result.engine}: {result.message}" + + +def execute_trace( + route: RouteSpec, scenario: TraceScenario, mode: TraceMode, surface: Surface +) -> TraceComparisonArtifact: + asynchronous: Final = mode == "async" + mappings: Final = scenario.mappings_for(mode) + scenario_route: Final = RouteSpec( + route=route.route, + python_entrypoints=route.python_entrypoints, + rust_entrypoints=route.rust_entrypoints, + fixture=scenario.fixture, + ) + python_trace: Final = collect_trace(scenario_route, "python", asynchronous=asynchronous) + rust_trace: Final = collect_trace(scenario_route, "rust", asynchronous=asynchronous) + python_error: Final = _failure_message(python_trace) + rust_error: Final = _failure_message(rust_trace) + python_events: Final = python_trace if isinstance(python_trace, tuple) else () + rust_events: Final = rust_trace if isinstance(rust_trace, tuple) else () + try: + python: Final = pipeline_projection("python", python_events, mappings) + rust: Final = pipeline_projection("rust", rust_events, mappings) + except ValueError as error: + return TraceComparisonArtifact.from_traces( + surface=surface, + sdk_function=route.route, + scenario=scenario.name, + mode=mode, + mappings=mappings, + contract=scenario.contract, + python=(), + rust=(), + python_unmatched=0, + python_error=f"harness: {error}", + ) + return TraceComparisonArtifact.from_traces( + surface=surface, + sdk_function=route.route, + scenario=scenario.name, + mode=mode, + mappings=mappings, + contract=scenario.contract, + python=python.steps, + rust=rust.steps, + python_unmatched=python.unmatched, + python_error=python_error, + rust_error=rust_error, + ) diff --git a/tests/rust-python-harness/strategies/trace_parity/sdk/messages/case.py b/tests/rust-python-harness/strategies/trace_parity/sdk/messages/case.py new file mode 100644 index 00000000000..27079c28cd8 --- /dev/null +++ b/tests/rust-python-harness/strategies/trace_parity/sdk/messages/case.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +import json +from typing import Final + +from .....shared.parity.recorded_http import HttpHeader, RecordedHttpResponse +from .....shared.tracing.steps import Engine, mapping +from ...models import RouteFixture, RouteSpec, TraceScenario, TraceSuite + +COMMON_MAPPINGS: Final = ( + mapping(rust_span="messages", python_frame=r"anthropic_interface/messages/__init__\.py:\d+ a?create$"), + mapping( + span="python_messages_provider_config", + python_frame=r"ProviderConfigManager\.get_provider_anthropic_messages_config$", + ), + mapping(rust_span="messages_provider_config"), + mapping(rust_span="validate_environment", python_frame=r"validate_anthropic_messages_environment$"), + mapping(rust_span="complete_url", python_frame=r"get_complete_url$"), + mapping( + span="python_messages_entry_handler", + python_frame=r"messages/handler\.py:\d+ anthropic_messages_handler$", + ), + mapping( + span="python_messages_handler_wrapper", + python_frame=r"BaseLLMHTTPHandler\.anthropic_messages_handler$", + ), + mapping( + rust_span="execute_messages_provider_call", + python_frame=r"BaseLLMHTTPHandler\.async_anthropic_messages_handler$", + ), + mapping(rust_span="http_request", python_frame=r"AsyncHTTPHandler\.post$|HTTPHandler\.post$"), + mapping(rust_span="transform_response", python_frame=r"(? RouteFixture: + conversation: Final = {"messages": [{"role": "user", "content": "hello"}], "max_tokens": 16} + response: Final = json.dumps( + { + "id": "msg_trace", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-5", + "content": [{"type": "text", "text": "hello"}], + "stop_reason": "end_turn", + "stop_sequence": None, + "usage": {"input_tokens": 2, "output_tokens": 3}, + } + ).encode() + return RouteFixture( + kwargs={ + "model": f"{provider}/claude-sonnet-5", + **({"body": {**conversation, "model": "claude-sonnet-5"}} if engine == "rust" else conversation), + }, + provider_responses=( + RecordedHttpResponse.from_bytes( + 200, (HttpHeader(name="content-type", value="application/json"),), response + ), + ), + ) + + +def _anthropic_fixture(engine: Engine, _base_url: str) -> RouteFixture: + return _fixture(engine, "anthropic") + + +def _azure_fixture(engine: Engine, _base_url: str) -> RouteFixture: + return _fixture(engine, "azure_ai") + + +SPEC: Final = RouteSpec("messages", ("create", "acreate"), ("messages", "amessages"), _anthropic_fixture) +TRACE_SUITE: Final = TraceSuite( + route=SPEC, + scenarios=( + TraceScenario(name="anthropic", fixture=_anthropic_fixture, mappings=ANTHROPIC_MAPPINGS, modes=("async",)), + TraceScenario(name="azure-ai", fixture=_azure_fixture, mappings=AZURE_MAPPINGS, modes=("async",)), + ), +) diff --git a/tests/rust-python-harness/strategies/trace_parity/sdk/ocr/case.py b/tests/rust-python-harness/strategies/trace_parity/sdk/ocr/case.py new file mode 100644 index 00000000000..fe214f45339 --- /dev/null +++ b/tests/rust-python-harness/strategies/trace_parity/sdk/ocr/case.py @@ -0,0 +1,339 @@ +from __future__ import annotations + +import json +from typing import Final, cast + +from .....shared.parity.recorded_http import HttpHeader, RecordedHttpResponse +from .....shared.tracing.steps import Engine, mapping +from ...models import RouteFixture, RouteSpec, TraceScenario, TraceSuite + +COMMON_MAPPINGS: Final = ( + mapping(rust_span="ocr", python_frame=r"ocr/main\.py:\d+ a?ocr$"), + mapping(rust_span="prepare_ocr_call", python_frame=r"ocr/main\.py:\d+ _prepare_ocr_request$"), + mapping(rust_span="ocr_provider_config", python_frame=r"ProviderConfigManager\.get_provider_ocr_config$"), + mapping(rust_span="supported_ocr_params", python_frame=r"get_supported_ocr_params$"), + mapping(rust_span="map_ocr_params", python_frame=r"(? RouteFixture: + response: Final = json.dumps( + { + "pages": [{"index": 0, "markdown": "hello"}], + "model": "mistral-ocr-latest", + "usage_info": {"pages_processed": 1}, + } + ).encode() + return RouteFixture( + kwargs={ + "model": model, + "document": document or {"type": "document_url", "document_url": "https://example.com/document.pdf"}, + **({"optional_params": {"pages": [0]}} if engine == "rust" else {"pages": [0]}), + }, + provider_responses=( + RecordedHttpResponse.from_bytes( + 200, (HttpHeader(name="content-type", value="application/json"),), response + ), + ), + ) + + +def _mistral_fixture(engine: Engine, _base_url: str) -> RouteFixture: + return _fixture(engine, "mistral/mistral-ocr-latest") + + +def _azure_fixture(engine: Engine, _base_url: str) -> RouteFixture: + return _fixture( + engine, + "azure_ai/pixtral-12b-2409", + {"type": "image_url", "image_url": "data:image/png;base64,aGVsbG8="}, + ) + + +def _vertex_fixture(engine: Engine, _base_url: str) -> RouteFixture: + fixture: Final = _fixture( + engine, + "vertex_ai/mistral-ocr-maas", + {"type": "image_url", "image_url": "data:image/png;base64,aGVsbG8="}, + ) + vertex: Final = {"vertex_project": "trace-project", "vertex_location": "us-central1"} + optional_params: Final = cast(dict[str, object], fixture.kwargs.get("optional_params", {})) + return RouteFixture( + kwargs={ + **fixture.kwargs, + **({"optional_params": {**optional_params, **vertex}} if engine == "rust" else vertex), + }, + provider_responses=fixture.provider_responses, + ) + + +def _vertex_deepseek_fixture(engine: Engine, _base_url: str) -> RouteFixture: + vertex: Final = {"vertex_project": "trace-project", "vertex_location": "us-central1"} + return RouteFixture( + kwargs={ + "model": "vertex_ai/deepseek-ocr-maas", + "document": {"type": "image_url", "image_url": "data:image/png;base64,aGVsbG8="}, + **({"optional_params": vertex} if engine == "rust" else vertex), + }, + provider_responses=( + RecordedHttpResponse.from_bytes( + 200, + (HttpHeader(name="content-type", value="application/json"),), + json.dumps( + { + "choices": [{"message": {"role": "assistant", "content": "hello"}}], + "usage": {"prompt_tokens": 1, "completion_tokens": 1}, + } + ).encode(), + ), + ), + ) + + +def _azure_document_intelligence_fixture(engine: Engine, base_url: str) -> RouteFixture: + completed: Final = json.dumps( + { + "status": "succeeded", + "analyzeResult": { + "content": "hello", + "pages": [ + { + "pageNumber": 1, + "width": 8.5, + "height": 11, + "unit": "inch", + "lines": [{"content": "hello"}], + } + ], + }, + } + ).encode() + return RouteFixture( + kwargs={ + "model": "azure_ai/doc-intelligence/prebuilt-read", + "document": { + "type": "document_url", + "document_url": "data:application/pdf;base64,aGVsbG8=", + }, + **({"optional_params": {"pages": [0]}} if engine == "rust" else {"pages": [0]}), + }, + provider_responses=( + RecordedHttpResponse.from_bytes( + 202, + ( + HttpHeader(name="content-type", value="application/json"), + HttpHeader(name="operation-location", value=f"{base_url}/operations/trace"), + ), + b"{}", + ), + RecordedHttpResponse.from_bytes( + 200, + (HttpHeader(name="content-type", value="application/json"),), + completed, + ), + ), + ) + + +VERTEX_COMMON_MAPPINGS: Final = ( + *COMMON_MAPPINGS[:7], + mapping( + rust_span="transform_ocr_request", + python_frame=( + r"VertexAIOCRConfig\.(?:async_)?transform_ocr_request$" + r"|MistralOCRConfig\.transform_ocr_request$" + ), + ), + COMMON_MAPPINGS[-1], +) +VERTEX_SYNC_MAPPINGS: Final = ( + *VERTEX_COMMON_MAPPINGS, + mapping(rust_span="execute_ocr_provider_call", python_frame=r"BaseLLMHTTPHandler\.ocr$"), + mapping(span="python_transform_ocr_response_wrapper", python_frame=r"BaseLLMHTTPHandler\._transform_ocr_response$"), + mapping(rust_span="transform_ocr_response", python_frame=r"MistralOCRConfig\.transform_ocr_response$"), +) +VERTEX_ASYNC_MAPPINGS: Final = ( + *VERTEX_COMMON_MAPPINGS, + mapping(span="python_ocr_wrapper", python_frame=r"BaseLLMHTTPHandler\.ocr$"), + mapping(rust_span="execute_ocr_provider_call", python_frame=r"BaseLLMHTTPHandler\.async_ocr$"), + mapping(rust_span="transform_ocr_response", python_frame=r"MistralOCRConfig\.transform_ocr_response$"), +) + +DEEPSEEK_COMMON_MAPPINGS: Final = ( + mapping(rust_span="ocr", python_frame=r"ocr/main\.py:\d+ a?ocr$"), + mapping(rust_span="prepare_ocr_call", python_frame=r"ocr/main\.py:\d+ _prepare_ocr_request$"), + mapping(rust_span="ocr_provider_config", python_frame=r"ProviderConfigManager\.get_provider_ocr_config$"), + mapping(rust_span="supported_ocr_params", python_frame=r"get_supported_ocr_params$"), + mapping(rust_span="map_ocr_params", python_frame=r"(? bytes: + with io.BytesIO() as buffer: + with wave.open(buffer, "wb") as audio: + audio.setnchannels(1) + audio.setsampwidth(2) + audio.setframerate(16000) + audio.writeframes(b"\x00\x00" * 1600) + return buffer.getvalue() + + +def _fixture(engine: Engine, _base_url: str) -> RouteFixture: + credentials: Final = { + "aws_access_key_id": "test-access", + "aws_secret_access_key": "test-secret", + "aws_region_name": "us-east-1", + } + audio: Final = _audio_bytes() + payload: Final = ( + {"audio": {"data": base64.b64encode(audio).decode(), "format": "wav"}, "optional_params": credentials} + if engine == "rust" + else {"file": ("sample.wav", audio, "audio/wav"), **credentials} + ) + response: Final = json.dumps( + { + "output": {"message": {"role": "assistant", "content": [{"text": "hello"}]}}, + "stopReason": "end_turn", + "usage": {"inputTokens": 2, "outputTokens": 3, "totalTokens": 5}, + } + ).encode() + return RouteFixture( + kwargs={"model": "bedrock/mistral.voxtral-mini-3b-2507", **payload}, + provider_responses=( + RecordedHttpResponse.from_bytes( + 200, (HttpHeader(name="content-type", value="application/json"),), response + ), + ), + ) + + +SPEC: Final = RouteSpec( + "transcription", + ("transcription", "atranscription"), + ("transcription", "atranscription"), + _fixture, +) +TRACE_SUITE: Final = TraceSuite( + route=SPEC, + scenarios=( + TraceScenario( + name="bedrock", + fixture=_fixture, + mappings=MAPPINGS, + sync_mappings=SYNC_MAPPINGS, + async_mappings=ASYNC_MAPPINGS, + ), + ), +) diff --git a/tests/rust-python-harness/strategies/trace_parity/strategy.json b/tests/rust-python-harness/strategies/trace_parity/strategy.json deleted file mode 100644 index 9b67d8570cc..00000000000 --- a/tests/rust-python-harness/strategies/trace_parity/strategy.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "order": 20, - "id": "trace_parity", - "label": "Trace parity", - "description": "Compare mapped operations, call counts, and required execution ordering.", - "functions": { - "ocr": { - "coverage": "planned", - "selectors": [] - }, - "messages": { - "coverage": "planned", - "selectors": [] - }, - "chat_completions": { - "coverage": "planned", - "selectors": [] - }, - "responses": { - "coverage": "planned", - "selectors": [] - }, - "count_tokens": { - "coverage": "planned", - "selectors": [] - }, - "transcription": { - "coverage": "planned", - "selectors": [] - } - }, - "gateway": {} -} diff --git a/tests/rust-python-harness/strategies/trace_parity/test_reporting.py b/tests/rust-python-harness/strategies/trace_parity/test_reporting.py new file mode 100644 index 00000000000..68264064c92 --- /dev/null +++ b/tests/rust-python-harness/strategies/trace_parity/test_reporting.py @@ -0,0 +1,251 @@ +from __future__ import annotations + +from collections.abc import Sequence +from typing import Final, Literal + +import pytest + +from ...shared.reporting.models import CaseResult, Coverage, HarnessCase, ResultArtifact, RunStatus +from ...shared.reporting.strategy import ModuleCaseSpec, NotImplementedCaseSpec +from ...shared.tracing.steps import PipelineStep, TraceContract, TraceMapping, mapping +from . import reporting +from .reporting import TRACE_COMPARISON_ARTIFACT, TraceComparisonArtifact, render_trace_results + +MAPPINGS: Final = ( + mapping(rust_span="ocr", python_frame=r"ocr/main\.py:\d+ a?ocr$"), + mapping(rust_span="http_request", python_frame=r"AsyncHTTPHandler\.post$"), +) + + +def _result(comparison: TraceComparisonArtifact) -> CaseResult: + case: Final = HarnessCase( + strategy_id="trace_parity", + strategy_label="Trace parity", + sdk_function=comparison.sdk_function, + spec=ModuleCaseSpec(coverage=Coverage.PARTIAL, module="example"), + surface=comparison.surface, + ) + result: Final = CaseResult(case=case) + nodeid: Final = f"trace:sdk:{comparison.sdk_function}:{comparison.scenario}:{comparison.mode}" + result.collected.add(nodeid) + result.record( + nodeid, + RunStatus.PASSED, + artifacts=(ResultArtifact(TRACE_COMPARISON_ARTIFACT, comparison.model_dump_json()),), + ) + return result + + +def _comparison( + python: tuple[PipelineStep, ...], + rust: tuple[PipelineStep, ...], + *, + mappings: Sequence[TraceMapping] = MAPPINGS, + rust_error: str | None = None, +) -> TraceComparisonArtifact: + return TraceComparisonArtifact.from_traces( + surface="sdk", + sdk_function="ocr", + scenario="default", + mode="sync", + mappings=mappings, + contract=TraceContract(), + python=python, + rust=rust, + python_unmatched=796, + rust_error=rust_error, + ) + + +def _events(*items: tuple[str, int, str | None]) -> tuple[PipelineStep, ...]: + parents: dict[int, int] = {} + steps: list[PipelineStep] = [] + for event_id, (span, depth, raw) in enumerate(items): + parent_id = parents.get(depth - 1) if depth else None + steps.append(PipelineStep(event_id, parent_id, span, raw if raw is not None else span)) + parents[depth] = event_id + return tuple(steps) + + +def test_renderer_shows_matching_python_and_rust_paths() -> None: + rust: Final = _events(("ocr", 0, None), ("http_request", 1, None)) + python: Final = _events( + ("ocr", 0, "ocr/main.py:88 aocr"), + ("http_request", 1, "http_handler.py:673 AsyncHTTPHandler.post"), + ) + + section: Final = render_trace_results((_result(_comparison(python, rust)),))[0] + report: Final = "\n\n".join(section.blocks) + + assert section.title == "SDK trace comparisons" + assert "Case: ocr" in report + assert "PYTHON (2 steps)\n1 aocr (ocr/main.py:88)\n2 AsyncHTTPHandler.post (http_handler.py:673)" in report + assert "RUST (2 steps)\nocr -> 1 aocr\n http_request -> 2 AsyncHTTPHandler.post" in report + assert "Mapping (identifier -> span)" not in report + assert "Trace: MATCH" in report + assert "Same steps, order, and nesting" in report + assert "Unseen mappings:" not in report + + +def test_renderer_reports_mappings_that_matched_nothing() -> None: + events: Final = _events(("ocr", 0, None)) + + section: Final = render_trace_results((_result(_comparison(events, events)),))[0] + report: Final = "\n\n".join(section.blocks) + + assert "Unseen mappings: http_request" in report + assert "Contract: FAIL" in report + + +def test_renderer_numbers_repeated_span_occurrences() -> None: + mappings: Final = (MAPPINGS[0], MAPPINGS[1]) + rust: Final = _events(("ocr", 0, None), ("http_request", 1, None), ("http_request", 1, None)) + python: Final = _events( + ("ocr", 0, "ocr/main.py:88 aocr"), + ("http_request", 1, "http_handler.py:673 AsyncHTTPHandler.post"), + ("http_request", 1, "http_handler.py:673 AsyncHTTPHandler.post"), + ) + + report: Final = "\n\n".join(render_trace_results((_result(_comparison(python, rust, mappings=mappings)),))[0].blocks) + + assert "http_request#2" in report + + +def test_renderer_accepts_declared_engine_specific_steps() -> None: + mappings: Final = ( + *MAPPINGS[:1], + mapping(span="python_prepare", python_frame=r"python_prepare$"), + mapping(rust_span="rust_prepare"), + ) + python: Final = _events(("ocr", 0, None), ("python_prepare", 1, "prep.py:1 python_prepare")) + rust: Final = _events(("ocr", 0, None), ("rust_prepare", 1, None)) + + section: Final = render_trace_results((_result(_comparison(python, rust, mappings=mappings)),))[0] + report: Final = "\n\n".join(section.blocks) + + assert "2 python_prepare (prep.py:1) [python only]" in report + assert "rust_prepare -> [rust only]" in report + assert "Trace: MATCH" in report + assert "Contract: PASS" in report + + +def test_unavailable_check_reports_mode_from_nodeid() -> None: + case: Final = HarnessCase( + strategy_id="trace_parity", + strategy_label="Trace parity", + sdk_function="ocr", + spec=ModuleCaseSpec(coverage=Coverage.PARTIAL, module="example"), + surface="sdk", + ) + result: Final = CaseResult(case=case) + result.collected.add("trace:sdk:ocr:default:sync") + result.record("trace:sdk:ocr:default:sync", RunStatus.ERROR) + + section: Final = render_trace_results((result,))[0] + report: Final = "\n\n".join(section.blocks) + + assert "Case: ocr" in report + assert "Scenario: default / Mode: sync" in report + assert "Trace: NOT AVAILABLE\nTest outcome: error" in report + assert "unknown mode" not in report + + +def test_renderer_keeps_collected_trace_when_one_engine_errors() -> None: + python: Final = _events( + ("ocr", 0, "ocr/main.py:88 aocr"), + ("http_request", 1, "http_handler.py:673 AsyncHTTPHandler.post"), + ) + + section: Final = render_trace_results( + (_result(_comparison(python, (), rust_error="rust: native Rust bridge must include the trace-parity feature")),) + )[0] + report: Final = "\n\n".join(section.blocks) + + assert "PYTHON (2 steps)\n1 aocr (ocr/main.py:88) [python only]" in report + assert "Rust error: rust: native Rust bridge must include the trace-parity feature" in report + assert "hint: rebuild the native bridge with the trace-parity feature" in report + assert "Contract: FAIL" in report + + +def test_renderer_groups_all_modes_under_one_case_header() -> None: + case: Final = HarnessCase( + strategy_id="trace_parity", + strategy_label="Trace parity", + sdk_function="ocr", + spec=ModuleCaseSpec(coverage=Coverage.PARTIAL, module="example"), + surface="sdk", + ) + result: Final = CaseResult(case=case) + events: Final = _events(("ocr", 0, None)) + modes: Final[tuple[Literal["sync", "async"], ...]] = ("sync", "async") + for mode in modes: + nodeid = f"trace:sdk:ocr:default:{mode}" + result.collected.add(nodeid) + comparison = TraceComparisonArtifact.from_traces( + surface="sdk", + sdk_function="ocr", + scenario="default", + mode=mode, + mappings=MAPPINGS, + contract=TraceContract(), + python=events, + rust=events, + python_unmatched=0, + ) + result.record( + nodeid, + RunStatus.PASSED, + artifacts=(ResultArtifact(TRACE_COMPARISON_ARTIFACT, comparison.model_dump_json()),), + ) + + section: Final = render_trace_results((result,))[0] + + assert len(section.blocks) == 1 + report: Final = section.blocks[0] + assert report.count("Case: ocr") == 1 + assert "Scenario: default / Mode: sync" in report + assert "Scenario: default / Mode: async" in report + + +def test_renderer_colors_every_trace_line_in_a_terminal(monkeypatch: pytest.MonkeyPatch) -> None: + rust: Final = _events(("ocr", 0, None), ("http_request", 1, None)) + python: Final = _events( + ("ocr", 0, "ocr/main.py:88 aocr"), + ("http_request", 1, "http_handler.py:673 AsyncHTTPHandler.post"), + ) + monkeypatch.setattr(reporting.sys.stdout, "isatty", lambda: True) + monkeypatch.delenv("NO_COLOR", raising=False) + + section: Final = render_trace_results((_result(_comparison(python, rust)),))[0] + report: Final = "\n\n".join(section.blocks) + + assert "\033[36mPYTHON\033[0m (2 steps)" in report + assert "\033[36m1 aocr (ocr/main.py:88)\033[0m" in report + assert "\033[33mRUST\033[0m (2 steps)" in report + assert "\033[33mocr\033[0m -> \033[36m1 aocr\033[0m" in report + assert "\033[33mhttp_request\033[0m -> \033[36m2 AsyncHTTPHandler.post\033[0m" in report + + +def test_renderer_groups_cases_and_unavailable_entries_by_surface() -> None: + events: Final = _events(("ocr", 0, None)) + gateway_results: Final = tuple( + CaseResult( + case=HarnessCase( + strategy_id="trace_parity", + strategy_label="Trace parity", + sdk_function=sdk_function, + spec=NotImplementedCaseSpec(reason=f"No {sdk_function} case is registered."), + surface="gateway", + ), + status=RunStatus.NOT_IMPLEMENTED, + ) + for sdk_function in ("ocr", "messages") + ) + + sections: Final = render_trace_results((_result(_comparison(events, events)), *gateway_results)) + + assert tuple(section.title for section in sections) == ("SDK trace comparisons", "GATEWAY trace comparisons") + gateway_report: Final = "\n\n".join(sections[1].blocks) + assert gateway_report.count("Not implemented") == 1 + assert "- ocr: No ocr case is registered." in gateway_report + assert "- messages: No messages case is registered." in gateway_report diff --git a/tests/rust-python-harness/strategies/trace_parity/test_runner.py b/tests/rust-python-harness/strategies/trace_parity/test_runner.py new file mode 100644 index 00000000000..0fcc5860ff2 --- /dev/null +++ b/tests/rust-python-harness/strategies/trace_parity/test_runner.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +from typing import Final + +from ...shared.reporting.models import Coverage, HarnessCase, HarnessRun, RunStatus, SdkFunction, Surface +from ...shared.reporting.strategy import ModuleCaseSpec +from ...shared.tracing.steps import Engine +from .models import GatewayRouteSpec, RouteFixture, RouteSpec, TraceScenario, TraceSuite +from .runner import run_trace_mode, scenario_nodeids, validate_trace_suite + + +def _fixture(_engine: Engine, _base_url: str) -> RouteFixture: + return RouteFixture(kwargs={}, provider_responses=()) + + +def _case(*, surface: Surface = "sdk", function: SdkFunction = "ocr") -> HarnessCase: + return HarnessCase( + strategy_id="trace_parity", + strategy_label="Trace parity", + sdk_function=function, + spec=ModuleCaseSpec(coverage=Coverage.PARTIAL, module="example"), + surface=surface, + ) + + +def test_scenario_filtering_and_occurrence_node_ids() -> None: + suite: Final = TraceSuite( + route=RouteSpec("ocr", ("ocr", "aocr"), ("ocr", "aocr"), _fixture), + scenarios=( + TraceScenario("one", _fixture, (), modes=("sync", "async")), + TraceScenario("two", _fixture, (), modes=("async",)), + ), + ) + case: Final = _case() + + nodes: Final = scenario_nodeids(suite, case, frozenset({"two"})) + + assert tuple(nodeid for _, _, nodeid in nodes) == ("trace:sdk:ocr:two:async",) + + +def test_scenario_validation_rejects_duplicate_and_unsafe_names() -> None: + route: Final = RouteSpec("ocr", ("ocr", "aocr"), ("ocr", "aocr"), _fixture) + duplicate: Final = TraceSuite( + route=route, + scenarios=(TraceScenario("same", _fixture, ()), TraceScenario("same", _fixture, ())), + ) + unsafe: Final = TraceSuite(route=route, scenarios=(TraceScenario("bad:name", _fixture, ()),)) + case: Final = _case() + + assert validate_trace_suite(duplicate, case) is not None + assert validate_trace_suite(unsafe, case) is not None + + +def test_scenario_validation_rejects_invalid_modes_and_route_registration() -> None: + invalid_modes: Final = TraceSuite( + route=RouteSpec("ocr", ("ocr", "aocr"), ("ocr", "aocr"), _fixture), + scenarios=(TraceScenario("invalid", _fixture, (), modes=("sync", "sync")),), + ) + wrong_function: Final = TraceSuite( + route=RouteSpec("messages", ("create", "acreate"), ("messages", "amessages"), _fixture), + scenarios=(TraceScenario("one", _fixture, ()),), + ) + wrong_surface: Final = TraceSuite( + route=GatewayRouteSpec("ocr"), + scenarios=(TraceScenario("one", _fixture, ()),), + ) + case: Final = _case() + + assert "unique sync/async modes" in (validate_trace_suite(invalid_modes, case) or "") + assert "does not match case function" in (validate_trace_suite(wrong_function, case) or "") + assert "must use RouteSpec" in (validate_trace_suite(wrong_surface, case) or "") + + +def test_invalid_route_dispatch_records_harness_error() -> None: + case: Final = _case() + run: Final = HarnessRun.from_cases((case,)) + result: Final = run.results[case.key] + suite: Final = TraceSuite( + route=GatewayRouteSpec("ocr"), + scenarios=(TraceScenario("one", _fixture, (), modes=("sync",)),), + ) + nodeid: Final = "trace:sdk:ocr:one:sync" + + run_trace_mode(run, result, suite, suite.scenarios[0], "sync", "sdk", nodeid, lambda _: None) + + assert result.outcomes[nodeid] is RunStatus.ERROR + assert run.failures == [(nodeid, "gateway route cannot run on the sdk surface")] diff --git a/tests/rust-python-harness/strategies/unit_tests/README.md b/tests/rust-python-harness/strategies/unit_tests/README.md deleted file mode 100644 index bd37072ae4b..00000000000 --- a/tests/rust-python-harness/strategies/unit_tests/README.md +++ /dev/null @@ -1,7 +0,0 @@ -# Unit tests - -Run independently with `uv run python -m tests.rust-python-harness.strategies.unit_tests.runner --plain`. Configure a `unit_suite` for each mapped API in `strategy.json` - -The runner combines mapping validation, Python tests in separate verified backend processes, and Cargo tests. It reports missing and ambiguous counterparts. Native Rust tests and existing Python tests stay in their original locations - -See [the suite format](../../README.md#configure-cases) for configuration. No complete API mapping is configured yet diff --git a/tests/rust-python-harness/strategies/unit_tests/__init__.py b/tests/rust-python-harness/strategies/unit_tests/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/tests/rust-python-harness/strategies/unit_tests/ledgers/ocr/ocr_test_ledger.json b/tests/rust-python-harness/strategies/unit_tests/ledgers/ocr/ocr_test_ledger.json deleted file mode 100644 index 1ceb79b52bc..00000000000 --- a/tests/rust-python-harness/strategies/unit_tests/ledgers/ocr/ocr_test_ledger.json +++ /dev/null @@ -1,211 +0,0 @@ -{ - "sdk_function": "ocr", - "python_scope": [ - "tests/test_litellm/llms/azure_ai/test_azure_document_intelligence_ocr_transformation.py", - "tests/test_litellm/ocr/test_ocr_azure_document_intelligence_api_base.py", - "tests/test_litellm/ocr/test_rust_bridge.py", - "tests/test_litellm/ocr/test_ocr_file_input.py", - "tests/test_litellm/llms/mistral/ocr/test_mistral_ocr_transformation.py", - "tests/test_litellm/llms/mistral/ocr/test_mistral_ocr_cost.py", - "tests/test_litellm/ocr/test_ocr_native_format.py", - "tests/test_litellm/llms/ocr/guardrail_translation/test_ocr_guardrail_handler.py", - "tests/test_litellm/proxy/ocr_endpoints/test_endpoints.py" - ], - "rust_scope": [ - "litellm-rust/crates/ai-gateway/src/ocr/common_utils.rs", - "litellm-rust/crates/ai-gateway/src/ocr/tests.rs", - "litellm-rust/crates/core/src/providers/azure_ai/ocr/transformation.rs", - "litellm-rust/crates/core/src/providers/vertex_ai/ocr/transformation.rs", - "litellm-rust/crates/core/src/providers/mistral/ocr/transformation.rs", - "litellm-rust/crates/ai-gateway/src/integrations/custom_logger/mod.rs" - ], - "entries": [ - {"python_file": "tests/test_litellm/llms/azure_ai/test_azure_document_intelligence_ocr_transformation.py", "python_test": "test_should_encode_azure_document_intelligence_model_id", "status": "unmapped", "reason": "model-id URL percent-encoding has no Rust test; Rust only tests pages/features query building"}, - {"python_file": "tests/test_litellm/llms/azure_ai/test_azure_document_intelligence_ocr_transformation.py", "python_test": "test_should_reject_dot_segment_azure_document_intelligence_model_id", "status": "unmapped", "reason": "model-id dot-segment validation has no Rust test"}, - {"python_file": "tests/test_litellm/llms/azure_ai/test_azure_document_intelligence_ocr_transformation.py", "python_test": "test_transform_ocr_response_preserves_azure_native_fields", "status": "mapped", "rust_file": "litellm-rust/crates/core/src/providers/azure_ai/ocr/transformation.rs", "rust_test": "document_intelligence_response_normalizes_pages", "justification": "both assert page markdown, dimension (inch-to-pixel) normalization, and usage_info.pages_processed from the same Azure succeeded response shape"}, - {"python_file": "tests/test_litellm/llms/azure_ai/test_azure_document_intelligence_ocr_transformation.py", "python_test": "test_async_transform_ocr_response_preserves_azure_native_fields", "status": "mapped", "rust_file": "litellm-rust/crates/core/src/providers/azure_ai/ocr/transformation.rs", "rust_test": "document_intelligence_response_normalizes_pages", "justification": "async twin of the sync case above, same underlying transform is exercised on the Rust side"}, - {"python_file": "tests/test_litellm/llms/azure_ai/test_azure_document_intelligence_ocr_transformation.py", "python_test": "test_transform_ocr_response_tolerates_missing_native_fields", "status": "unmapped", "reason": "tables/keyValuePairs absence tolerance is not asserted by the Rust response test"}, - {"python_file": "tests/test_litellm/llms/azure_ai/test_azure_document_intelligence_ocr_transformation.py", "python_test": "test_transform_ocr_response_non_succeeded_status_raises", "status": "unmapped", "reason": "no Rust test asserts on a non-succeeded Azure DI status"}, - {"python_file": "tests/test_litellm/llms/azure_ai/test_azure_document_intelligence_ocr_transformation.py", "python_test": "test_get_supported_ocr_params_includes_features", "status": "unmapped", "reason": "supported-params list content has no Rust equivalent for Azure"}, - {"python_file": "tests/test_litellm/llms/azure_ai/test_azure_document_intelligence_ocr_transformation.py", "python_test": "test_transform_ocr_response_native_format_carries_raw_operation", "status": "unmapped", "reason": "native req_format raw-operation passthrough is not tested in Rust"}, - {"python_file": "tests/test_litellm/llms/azure_ai/test_azure_document_intelligence_ocr_transformation.py", "python_test": "test_async_transform_ocr_response_native_format_carries_raw_operation", "status": "unmapped", "reason": "native req_format raw-operation passthrough is not tested in Rust"}, - {"python_file": "tests/test_litellm/llms/azure_ai/test_azure_document_intelligence_ocr_transformation.py", "python_test": "test_transform_ocr_response_default_format_omits_raw_operation", "status": "unmapped", "reason": "req_format gating of raw-operation output has no Rust test"}, - {"python_file": "tests/test_litellm/llms/azure_ai/test_azure_document_intelligence_ocr_transformation.py", "python_test": "test_map_ocr_params_passes_through_req_format", "status": "unmapped", "reason": "req_format passthrough in map_ocr_params has no Rust test"}, - {"python_file": "tests/test_litellm/llms/azure_ai/test_azure_document_intelligence_ocr_transformation.py", "python_test": "test_map_ocr_params_rejects_unknown_req_format_as_bad_request", "status": "unmapped", "reason": "req_format validation error path has no Rust test"}, - {"python_file": "tests/test_litellm/llms/azure_ai/test_azure_document_intelligence_ocr_transformation.py", "python_test": "test_get_complete_url_omits_req_format_query_param", "status": "unmapped", "reason": "no Rust test asserts req_format is excluded from the built URL"}, - {"python_file": "tests/test_litellm/llms/azure_ai/test_azure_document_intelligence_ocr_transformation.py", "python_test": "test_map_ocr_params_features", "status": "mapped", "rust_file": "litellm-rust/crates/core/src/providers/azure_ai/ocr/transformation.rs", "rust_test": "document_intelligence_url_normalizes_features", "justification": "both normalize comma-separated feature names and whitespace; Python does this during parameter mapping and Rust during URL construction"}, - {"python_file": "tests/test_litellm/llms/azure_ai/test_azure_document_intelligence_ocr_transformation.py", "python_test": "test_map_ocr_params_empty_features_list_omitted", "status": "mapped", "rust_file": "litellm-rust/crates/core/src/providers/azure_ai/ocr/transformation.rs", "rust_test": "document_intelligence_url_omits_empty_feature_list", "justification": "both omit empty feature lists from the outgoing request; Python removes the parameter and Rust omits the query field"}, - {"python_file": "tests/test_litellm/llms/azure_ai/test_azure_document_intelligence_ocr_transformation.py", "python_test": "test_map_ocr_params_invalid_features_raises", "status": "mapped", "rust_file": "litellm-rust/crates/core/src/providers/azure_ai/ocr/transformation.rs", "rust_test": "document_intelligence_url_rejects_invalid_features", "justification": "both reject malformed feature values, including query injection, empty strings, and objects before sending the request"}, - {"python_file": "tests/test_litellm/llms/azure_ai/test_azure_document_intelligence_ocr_transformation.py", "python_test": "test_get_complete_url_appends_features_query", "status": "mapped", "rust_file": "litellm-rust/crates/core/src/providers/azure_ai/ocr/transformation.rs", "rust_test": "document_intelligence_url_normalizes_features", "justification": "both assert the selected feature names appear in the outgoing features query parameter"}, - {"python_file": "tests/test_litellm/llms/azure_ai/test_azure_document_intelligence_ocr_transformation.py", "python_test": "test_get_complete_url_combines_pages_and_features", "status": "mapped", "rust_file": "litellm-rust/crates/core/src/providers/azure_ai/ocr/transformation.rs", "rust_test": "document_intelligence_url_combines_pages_and_feature_list", "justification": "both combine zero-based pages [0, 1, 2] with keyValuePairs and languages into pages=1,2,3 and features=keyValuePairs,languages"}, - {"python_file": "tests/test_litellm/llms/azure_ai/test_azure_document_intelligence_ocr_transformation.py", "python_test": "test_validate_environment_uses_subscription_key", "status": "unmapped", "reason": "Python-side header derivation from litellm_params; Rust's poll test only checks the header is present, not how it was resolved"}, - {"python_file": "tests/test_litellm/llms/azure_ai/test_azure_document_intelligence_ocr_transformation.py", "python_test": "test_validate_environment_falls_back_to_entra_token", "status": "unmapped", "reason": "Entra bearer-token fallback logic has no Rust test"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_azure_document_intelligence_api_base.py", "python_test": "TestIsAzureDocumentIntelligenceModel::test_matches_doc_intelligence_route", "status": "unmapped", "reason": "model-route string matching is Python-only dispatch logic"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_azure_document_intelligence_api_base.py", "python_test": "TestIsAzureDocumentIntelligenceModel::test_matches_documentintelligence_and_is_case_insensitive", "status": "unmapped", "reason": "model-route string matching is Python-only dispatch logic"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_azure_document_intelligence_api_base.py", "python_test": "TestIsAzureDocumentIntelligenceModel::test_does_not_match_mistral_route", "status": "unmapped", "reason": "model-route string matching is Python-only dispatch logic"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_azure_document_intelligence_api_base.py", "python_test": "TestDocIntelligenceApiBaseResolution::test_generic_azure_ai_base_does_not_hijack_doc_intelligence", "status": "unmapped", "reason": "api_base resolution from the secret manager runs before the Rust bridge is called, no Rust test exists for it"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_azure_document_intelligence_api_base.py", "python_test": "TestDocIntelligenceApiBaseResolution::test_explicit_api_base_is_honoured_for_doc_intelligence", "status": "unmapped", "reason": "api_base precedence resolution is Python-only"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_azure_document_intelligence_api_base.py", "python_test": "TestDocIntelligenceApiBaseResolution::test_generic_azure_ai_base_still_applies_to_mistral_ocr", "status": "unmapped", "reason": "api_base precedence resolution is Python-only"}, - - {"python_file": "tests/test_litellm/ocr/test_rust_bridge.py", "python_test": "test_use_litellm_rust_toggles_flag", "status": "unmapped", "reason": "bridge-plumbing: Python-side feature-flag toggle, no Rust equivalent"}, - {"python_file": "tests/test_litellm/ocr/test_rust_bridge.py", "python_test": "test_env_var_enables_rust_ocr", "status": "unmapped", "reason": "bridge-plumbing: Python-side env-var flag gating"}, - {"python_file": "tests/test_litellm/ocr/test_rust_bridge.py", "python_test": "test_explicit_false_overrides_process_enable", "status": "unmapped", "reason": "Python request-level Rust opt-out overrides the process flag before any Rust implementation runs"}, - {"python_file": "tests/test_litellm/ocr/test_rust_bridge.py", "python_test": "test_load_rust_ocr_returns_injected_impl", "status": "unmapped", "reason": "bridge-plumbing: dependency-injection test hook, not provider behavior"}, - {"python_file": "tests/test_litellm/ocr/test_rust_bridge.py", "python_test": "test_native_bridge_loader_returns_none_when_extension_absent", "status": "unmapped", "reason": "bridge-plumbing: native-extension import/loader fallback"}, - {"python_file": "tests/test_litellm/ocr/test_rust_bridge.py", "python_test": "test_native_bridge_loader_caches_absent_extension", "status": "unmapped", "reason": "bridge-plumbing: loader caching behavior"}, - {"python_file": "tests/test_litellm/ocr/test_rust_bridge.py", "python_test": "test_native_bridge_available_reflects_loader", "status": "unmapped", "reason": "bridge-plumbing: loader availability check"}, - {"python_file": "tests/test_litellm/ocr/test_rust_bridge.py", "python_test": "test_load_rust_aocr_returns_injected_impl", "status": "unmapped", "reason": "bridge-plumbing: dependency-injection test hook"}, - {"python_file": "tests/test_litellm/ocr/test_rust_bridge.py", "python_test": "test_toggle_without_ocr_arg_preserves_injected_impl", "status": "unmapped", "reason": "bridge-plumbing: injected-impl state retention regression"}, - {"python_file": "tests/test_litellm/ocr/test_rust_bridge.py", "python_test": "test_explicit_ocr_none_clears_injected_impl", "status": "unmapped", "reason": "bridge-plumbing: injected-impl clearing behavior"}, - {"python_file": "tests/test_litellm/ocr/test_rust_bridge.py", "python_test": "test_load_rust_ocr_none_when_extension_absent", "status": "unmapped", "reason": "bridge-plumbing: degrade path when the native extension is missing"}, - {"python_file": "tests/test_litellm/ocr/test_rust_bridge.py", "python_test": "test_load_rust_ocr_uses_compiled_extension", "status": "unmapped", "reason": "bridge-plumbing: native module resolution"}, - {"python_file": "tests/test_litellm/ocr/test_rust_bridge.py", "python_test": "test_timeout_to_seconds_handles_float_timeout_and_none", "status": "unmapped", "reason": "bridge-plumbing: Python-side timeout normalization helper"}, - {"python_file": "tests/test_litellm/ocr/test_rust_bridge.py", "python_test": "test_bridge_wrapper_forwards_prepared_args_and_wraps_response", "status": "unmapped", "reason": "bridge-plumbing: wrapper argument forwarding, asserted against a fake bridge not the real Rust code"}, - {"python_file": "tests/test_litellm/ocr/test_rust_bridge.py", "python_test": "test_bridge_wrapper_forwards_prepared_async_args_and_wraps_response", "status": "unmapped", "reason": "bridge-plumbing: async wrapper argument forwarding"}, - {"python_file": "tests/test_litellm/ocr/test_rust_bridge.py", "python_test": "test_run_rust_ocr_prepares_request_and_wraps_response", "status": "unmapped", "reason": "bridge-plumbing: request preparation and response wrapping in Python"}, - {"python_file": "tests/test_litellm/ocr/test_rust_bridge.py", "python_test": "test_run_rust_ocr_resolves_key_via_secret_manager_when_missing", "status": "unmapped", "reason": "secret-manager: API key resolution happens in Python before the bridge is invoked"}, - {"python_file": "tests/test_litellm/ocr/test_rust_bridge.py", "python_test": "test_run_rust_ocr_prefers_explicit_key_over_resolver", "status": "unmapped", "reason": "secret-manager: key precedence resolution"}, - {"python_file": "tests/test_litellm/ocr/test_rust_bridge.py", "python_test": "test_run_rust_ocr_uses_provider_api_key_env_var", "status": "unmapped", "reason": "secret-manager: provider-specific env var name resolution"}, - {"python_file": "tests/test_litellm/ocr/test_rust_bridge.py", "python_test": "test_prepare_rust_ocr_call_forwards_vertex_routing_metadata", "status": "unmapped", "reason": "secret-manager: vertex routing metadata merge happens in Python"}, - {"python_file": "tests/test_litellm/ocr/test_rust_bridge.py", "python_test": "test_prepare_rust_ocr_call_resolves_vertex_routing_metadata_from_secret_manager", "status": "unmapped", "reason": "secret-manager: vertex project/location resolution"}, - {"python_file": "tests/test_litellm/ocr/test_rust_bridge.py", "python_test": "test_prepare_rust_ocr_call_resolves_azure_ai_api_base_from_secret_manager", "status": "unmapped", "reason": "secret-manager: azure_ai api_base resolution"}, - {"python_file": "tests/test_litellm/ocr/test_rust_bridge.py", "python_test": "test_prepare_rust_ocr_call_resolves_document_intelligence_endpoint", "status": "unmapped", "reason": "secret-manager: doc-intelligence endpoint resolution"}, - {"python_file": "tests/test_litellm/ocr/test_rust_bridge.py", "python_test": "test_run_rust_ocr_runs_pre_call_logging", "status": "unmapped", "reason": "bridge-plumbing: Python logging-object pre_call invocation"}, - {"python_file": "tests/test_litellm/ocr/test_rust_bridge.py", "python_test": "test_ocr_routes_to_rust_when_enabled", "status": "unmapped", "reason": "bridge-plumbing: routing to a fake bridge, not the real Rust transform"}, - {"python_file": "tests/test_litellm/ocr/test_rust_bridge.py", "python_test": "test_ocr_routes_azure_ai_to_rust_when_enabled", "status": "unmapped", "reason": "bridge-plumbing: provider-prefix stripping before routing"}, - {"python_file": "tests/test_litellm/ocr/test_rust_bridge.py", "python_test": "test_ocr_rust_path_converts_file_document_before_bridge", "status": "unmapped", "reason": "file-normalization: raw-bytes-to-data-URI conversion happens in Python before the bridge call"}, - {"python_file": "tests/test_litellm/ocr/test_rust_bridge.py", "python_test": "test_ocr_exception_type_uses_resolved_provider_context", "status": "unmapped", "reason": "bridge-plumbing: Python exception-type mapping on bridge failure"}, - {"python_file": "tests/test_litellm/ocr/test_rust_bridge.py", "python_test": "test_aocr_routes_to_async_rust_when_enabled", "status": "unmapped", "reason": "bridge-plumbing: async routing to a fake bridge"}, - {"python_file": "tests/test_litellm/ocr/test_rust_bridge.py", "python_test": "test_aocr_exception_type_uses_resolved_provider_context", "status": "unmapped", "reason": "bridge-plumbing: async exception-type mapping on bridge failure"}, - {"python_file": "tests/test_litellm/ocr/test_rust_bridge.py", "python_test": "test_ocr_does_not_route_to_rust_when_disabled", "status": "unmapped", "reason": "bridge-plumbing: Python control flow for the toggle-disabled branch, no Rust-owned behavior runs"}, - {"python_file": "tests/test_litellm/ocr/test_rust_bridge.py", "python_test": "test_ocr_falls_back_to_python_when_bridge_unavailable", "status": "unmapped", "reason": "bridge-plumbing: Python-only fallback when the compiled Rust extension is absent, Rust cannot test its own absence"}, - {"python_file": "tests/test_litellm/ocr/test_rust_bridge.py", "python_test": "test_ocr_forwards_timeout_to_rust", "status": "unmapped", "reason": "bridge-plumbing: asserts the Python call site forwards a timeout kwarg, Rust receives an already-constructed request"}, - {"python_file": "tests/test_litellm/ocr/test_rust_bridge.py", "python_test": "test_ocr_passes_default_request_timeout_to_rust", "status": "unmapped", "reason": "bridge-plumbing: asserts the Python call site supplies a default timeout kwarg, no Rust equivalent"}, - {"python_file": "tests/test_litellm/ocr/test_rust_bridge.py", "python_test": "test_ocr_provider_configs_expose_api_key_env_vars", "status": "unmapped", "reason": "asserts per-provider get_api_key_env_var() strings; the closest Rust test (ocr_dispatch_supports_migrated_providers) asserts provider dispatch/param resolution instead, not API key env var names"}, - - {"python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestGetMimeType::test_should_detect_pdf_mime_type", "status": "unmapped", "reason": "file-normalization: MIME detection is Python-only preprocessing before the bridge call"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestGetMimeType::test_should_detect_png_mime_type", "status": "unmapped", "reason": "file-normalization: MIME detection"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestGetMimeType::test_should_detect_jpg_mime_type", "status": "unmapped", "reason": "file-normalization: MIME detection"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestGetMimeType::test_should_detect_jpeg_mime_type", "status": "unmapped", "reason": "file-normalization: MIME detection"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestGetMimeType::test_should_detect_gif_mime_type", "status": "unmapped", "reason": "file-normalization: MIME detection"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestGetMimeType::test_should_detect_webp_mime_type", "status": "unmapped", "reason": "file-normalization: MIME detection"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestGetMimeType::test_should_detect_tiff_mime_type", "status": "unmapped", "reason": "file-normalization: MIME detection"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestGetMimeType::test_should_detect_tif_mime_type", "status": "unmapped", "reason": "file-normalization: MIME detection"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestGetMimeType::test_should_detect_bmp_mime_type", "status": "unmapped", "reason": "file-normalization: MIME detection"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestGetMimeType::test_should_be_case_insensitive", "status": "unmapped", "reason": "file-normalization: MIME detection case handling"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestGetMimeType::test_should_fallback_for_unknown_extension", "status": "unmapped", "reason": "file-normalization: MIME detection fallback"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestConvertFileDocumentToUrlDocument::test_should_convert_pdf_pathlib_path_to_document_url", "status": "unmapped", "reason": "file-normalization: local-path-to-data-URI conversion happens in Python"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestConvertFileDocumentToUrlDocument::test_should_convert_image_pathlib_path_to_image_url", "status": "unmapped", "reason": "file-normalization: local-path-to-data-URI conversion"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestConvertFileDocumentToUrlDocument::test_should_reject_bare_str_path", "status": "unmapped", "reason": "file-normalization: arbitrary-file-read guard on bare str paths"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestConvertFileDocumentToUrlDocument::test_should_convert_pathlib_path", "status": "unmapped", "reason": "file-normalization: local-path-to-data-URI conversion"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestConvertFileDocumentToUrlDocument::test_should_convert_raw_bytes", "status": "unmapped", "reason": "file-normalization: raw-bytes-to-data-URI conversion"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestConvertFileDocumentToUrlDocument::test_should_convert_raw_bytes_with_explicit_mime_type", "status": "unmapped", "reason": "file-normalization: explicit MIME override on raw bytes"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestConvertFileDocumentToUrlDocument::test_should_convert_raw_bytes_with_image_mime_type", "status": "unmapped", "reason": "file-normalization: explicit MIME override on raw bytes"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestConvertFileDocumentToUrlDocument::test_should_convert_file_like_object", "status": "unmapped", "reason": "file-normalization: file-like-object conversion"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestConvertFileDocumentToUrlDocument::test_should_convert_file_like_object_with_name", "status": "unmapped", "reason": "file-normalization: file-like-object name-based MIME detection"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestConvertFileDocumentToUrlDocument::test_should_raise_error_for_missing_file_field", "status": "unmapped", "reason": "file-normalization: missing-field validation"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestConvertFileDocumentToUrlDocument::test_should_raise_error_for_nonexistent_pathlib_path", "status": "unmapped", "reason": "file-normalization: missing-file validation"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestConvertFileDocumentToUrlDocument::test_should_raise_error_for_empty_file", "status": "unmapped", "reason": "file-normalization: empty-file validation"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestConvertFileDocumentToUrlDocument::test_should_raise_error_for_unsupported_type", "status": "unmapped", "reason": "file-normalization: unsupported input type validation"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestConvertFileDocumentToUrlDocument::test_should_raise_error_for_invalid_mime_type", "status": "unmapped", "reason": "file-normalization: MIME-type injection validation"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestConvertFileDocumentToUrlDocument::test_should_override_mime_type_for_pathlib_path", "status": "unmapped", "reason": "file-normalization: explicit MIME override precedence"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestBuildDocumentFromUpload::test_should_build_document_url_for_pdf", "status": "unmapped", "reason": "file-normalization: multipart upload conversion"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestBuildDocumentFromUpload::test_should_build_image_url_for_png", "status": "unmapped", "reason": "file-normalization: multipart upload conversion"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestBuildDocumentFromUpload::test_should_build_image_url_for_jpeg", "status": "unmapped", "reason": "file-normalization: multipart upload conversion"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestBuildDocumentFromUpload::test_should_detect_mime_from_filename_when_content_type_is_octet_stream", "status": "unmapped", "reason": "file-normalization: filename-based MIME fallback"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestBuildDocumentFromUpload::test_should_detect_mime_from_filename_when_content_type_is_none", "status": "unmapped", "reason": "file-normalization: filename-based MIME fallback"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestBuildDocumentFromUpload::test_should_fallback_to_octet_stream_for_unknown", "status": "unmapped", "reason": "file-normalization: default MIME fallback"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestBuildDocumentFromUpload::test_should_preserve_base64_content_correctly", "status": "unmapped", "reason": "file-normalization: binary round-trip through base64"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestBuildDocumentFromUpload::test_should_strip_mime_parameters_from_content_type", "status": "unmapped", "reason": "file-normalization: content-type parameter stripping"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestBuildDocumentFromUpload::test_should_strip_mime_parameters_with_multiple_params", "status": "unmapped", "reason": "file-normalization: content-type parameter stripping"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestProxySecurityGuard::test_should_reject_file_type_document_in_json_body", "status": "unmapped", "reason": "proxy-layer JSON-body file-type guard, a different mechanism than Rust's URL-fetch SSRF guard"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestProxySecurityGuard::test_should_accept_document_url_type_in_json_body", "status": "unmapped", "reason": "proxy-layer JSON-body parsing"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestProxySecurityGuard::test_should_raise_on_invalid_json_body", "status": "unmapped", "reason": "proxy-layer JSON-body parsing error path"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestProxySecurityGuard::test_should_ignore_document_form_field_injection", "status": "unmapped", "reason": "proxy-layer multipart form-field injection guard, a different mechanism than Rust's URL-fetch SSRF guard"}, - - {"python_file": "tests/test_litellm/llms/mistral/ocr/test_mistral_ocr_transformation.py", "python_test": "TestGetSupportedOcrParams::test_extract_header_in_supported_params", "status": "mapped", "rust_file": "litellm-rust/crates/core/src/providers/mistral/ocr/transformation.rs", "rust_test": "extract_header_is_a_supported_ocr_param", "justification": "both assert extract_header appears in the Mistral supported OCR params list"}, - {"python_file": "tests/test_litellm/llms/mistral/ocr/test_mistral_ocr_transformation.py", "python_test": "TestGetSupportedOcrParams::test_extract_footer_in_supported_params", "status": "mapped", "rust_file": "litellm-rust/crates/core/src/providers/mistral/ocr/transformation.rs", "rust_test": "extract_footer_is_a_supported_ocr_param", "justification": "both assert extract_footer appears in the Mistral supported OCR params list"}, - {"python_file": "tests/test_litellm/llms/mistral/ocr/test_mistral_ocr_transformation.py", "python_test": "TestGetSupportedOcrParams::test_existing_params_still_present", "status": "mapped", "rust_file": "litellm-rust/crates/core/src/providers/mistral/ocr/transformation.rs", "rust_test": "existing_ocr_params_remain_supported", "justification": "both assert the previously supported params are still present in the supported list"}, - {"python_file": "tests/test_litellm/llms/mistral/ocr/test_mistral_ocr_transformation.py", "python_test": "TestMapOcrParams::test_extract_header_passed_through", "status": "mapped", "rust_file": "litellm-rust/crates/core/src/providers/mistral/ocr/transformation.rs", "rust_test": "map_ocr_params_forwards_extract_header", "justification": "both assert extract_header alone survives map_ocr_params unchanged"}, - {"python_file": "tests/test_litellm/llms/mistral/ocr/test_mistral_ocr_transformation.py", "python_test": "TestMapOcrParams::test_extract_footer_passed_through", "status": "mapped", "rust_file": "litellm-rust/crates/core/src/providers/mistral/ocr/transformation.rs", "rust_test": "map_ocr_params_forwards_extract_footer", "justification": "both assert extract_footer alone survives map_ocr_params unchanged"}, - {"python_file": "tests/test_litellm/llms/mistral/ocr/test_mistral_ocr_transformation.py", "python_test": "TestMapOcrParams::test_extract_header_and_footer_together", "status": "mapped", "rust_file": "litellm-rust/crates/core/src/providers/mistral/ocr/transformation.rs", "rust_test": "map_ocr_params_forwards_extract_header_and_footer", "justification": "both assert header and footer passed together are both forwarded with their given values"}, - {"python_file": "tests/test_litellm/llms/mistral/ocr/test_mistral_ocr_transformation.py", "python_test": "TestMapOcrParams::test_unknown_param_is_dropped", "status": "mapped", "rust_file": "litellm-rust/crates/core/src/providers/mistral/ocr/transformation.rs", "rust_test": "map_ocr_params_drops_unknown_params", "justification": "both assert an unrecognized param key is dropped while a known one is kept"}, - {"python_file": "tests/test_litellm/llms/mistral/ocr/test_mistral_ocr_transformation.py", "python_test": "TestNewSupportedParams::test_new_param_in_supported_list", "status": "mapped", "rust_file": "litellm-rust/crates/core/src/providers/mistral/ocr/transformation.rs", "rust_test": "new_ocr_params_are_supported", "justification": "both assert each OCR4 param is in the supported list"}, - {"python_file": "tests/test_litellm/llms/mistral/ocr/test_mistral_ocr_transformation.py", "python_test": "TestNewParamsMapOcr::test_new_param_passed_through", "status": "mapped", "rust_file": "litellm-rust/crates/core/src/providers/mistral/ocr/transformation.rs", "rust_test": "map_ocr_params_forwards_new_ocr_params", "justification": "both assert each OCR4 param/value pair survives map_ocr_params unchanged"}, - {"python_file": "tests/test_litellm/llms/mistral/ocr/test_mistral_ocr_transformation.py", "python_test": "TestTransformOcrRequest::test_param_included_in_request_body", "status": "mapped", "rust_file": "litellm-rust/crates/core/src/providers/mistral/ocr/transformation.rs", "rust_test": "transform_ocr_request_includes_each_optional_param", "justification": "both assert each optional param value lands in the built request body alongside model/document with no files"}, - {"python_file": "tests/test_litellm/llms/mistral/ocr/test_mistral_ocr_transformation.py", "python_test": "TestTransformOcrRequest::test_multiple_new_params_together", "status": "mapped", "rust_file": "litellm-rust/crates/core/src/providers/mistral/ocr/transformation.rs", "rust_test": "transform_ocr_request_includes_multiple_new_params", "justification": "both assert multiple OCR4 params passed together all land in the same request body"}, - {"python_file": "tests/test_litellm/llms/mistral/ocr/test_mistral_ocr_transformation.py", "python_test": "TestTransformOcrResponseOcr4Fields::test_blocks_and_confidence_scores_preserved", "status": "mapped", "rust_file": "litellm-rust/crates/core/src/providers/mistral/ocr/transformation.rs", "rust_test": "transform_ocr_response_preserves_blocks_and_confidence_scores", "justification": "both assert blocks and confidence_scores survive the OCR response transform on the returned page"}, - {"python_file": "tests/test_litellm/llms/mistral/ocr/test_mistral_ocr_transformation.py", "python_test": "TestTransformOcrResponseOcr4Fields::test_ocr4_fields_survive_model_dump", "status": "mapped", "rust_file": "litellm-rust/crates/core/src/providers/mistral/ocr/transformation.rs", "rust_test": "transform_ocr_response_preserves_ocr4_page_fields", "justification": "both assert tables, hyperlinks, header and footer survive the OCR response transform on the returned page"}, - - {"python_file": "tests/test_litellm/llms/mistral/ocr/test_mistral_ocr_cost.py", "python_test": "test_model_info_ocr4_price", "status": "unmapped", "reason": "cost-calc: pricing/model-info lookup is Python-only"}, - {"python_file": "tests/test_litellm/llms/mistral/ocr/test_mistral_ocr_cost.py", "python_test": "test_ocr4_cost_scales_with_pages", "status": "unmapped", "reason": "cost-calc: per-page pricing math is Python-only"}, - {"python_file": "tests/test_litellm/llms/mistral/ocr/test_mistral_ocr_cost.py", "python_test": "test_ocr3_pricing_entry", "status": "unmapped", "reason": "cost-calc: cost-map JSON entry validation is Python-only"}, - {"python_file": "tests/test_litellm/llms/mistral/ocr/test_mistral_ocr_cost.py", "python_test": "test_ocr3_model_info_price", "status": "unmapped", "reason": "cost-calc: pricing/model-info lookup is Python-only"}, - {"python_file": "tests/test_litellm/llms/mistral/ocr/test_mistral_ocr_cost.py", "python_test": "test_ocr3_cost_scales_with_pages", "status": "unmapped", "reason": "cost-calc: per-page pricing math is Python-only"}, - {"python_file": "tests/test_litellm/llms/mistral/ocr/test_mistral_ocr_cost.py", "python_test": "test_ocr3_bills_ocr_and_annotation_pages_at_their_own_rates", "status": "unmapped", "reason": "cost-calc: mixed-rate billing math is Python-only"}, - {"python_file": "tests/test_litellm/llms/mistral/ocr/test_mistral_ocr_cost.py", "python_test": "test_ocr3_bills_annotation_only_response", "status": "unmapped", "reason": "cost-calc: annotation-only billing math is Python-only"}, - {"python_file": "tests/test_litellm/llms/mistral/ocr/test_mistral_ocr_cost.py", "python_test": "test_ocr3_bills_annotation_pages_when_pages_processed_missing", "status": "unmapped", "reason": "cost-calc: fallback billing math is Python-only"}, - {"python_file": "tests/test_litellm/llms/mistral/ocr/test_mistral_ocr_cost.py", "python_test": "test_azure_doc_ai_annotation_pages_fall_back_to_ocr_rate", "status": "unmapped", "reason": "cost-calc: fallback billing math is Python-only"}, - - {"python_file": "tests/test_litellm/ocr/test_ocr_native_format.py", "python_test": "test_rust_ocr_serves_default_format", "status": "unmapped", "reason": "request-format gating decision is made in Python before the Rust bridge is ever invoked"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_native_format.py", "python_test": "test_rust_ocr_skipped_for_native_format", "status": "unmapped", "reason": "request-format gating decision is made in Python before the Rust bridge is ever invoked"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_native_format.py", "python_test": "test_native_format_rejected_for_provider_without_support_as_bad_request", "status": "unmapped", "reason": "provider-support validation for req_format happens in Python"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_native_format.py", "python_test": "test_unknown_format_rejected_for_provider_without_support_as_bad_request", "status": "unmapped", "reason": "req_format validation error path is Python-only"}, - - {"python_file": "tests/test_litellm/llms/ocr/guardrail_translation/test_ocr_guardrail_handler.py", "python_test": "TestHandlerDiscovery::test_handler_discovered_for_ocr", "status": "unmapped", "reason": "guardrail-translation handler discovery is a Python proxy-layer concern"}, - {"python_file": "tests/test_litellm/llms/ocr/guardrail_translation/test_ocr_guardrail_handler.py", "python_test": "TestHandlerDiscovery::test_handler_discovered_for_aocr", "status": "unmapped", "reason": "guardrail-translation handler discovery is a Python proxy-layer concern"}, - {"python_file": "tests/test_litellm/llms/ocr/guardrail_translation/test_ocr_guardrail_handler.py", "python_test": "TestInputProcessing::test_process_document_url", "status": "unmapped", "reason": "scoped to the Python translation handler, not the Rust gateway's guardrail hook lifecycle"}, - {"python_file": "tests/test_litellm/llms/ocr/guardrail_translation/test_ocr_guardrail_handler.py", "python_test": "TestInputProcessing::test_process_image_url", "status": "unmapped", "reason": "scoped to the Python translation handler, not the Rust gateway's guardrail hook lifecycle"}, - {"python_file": "tests/test_litellm/llms/ocr/guardrail_translation/test_ocr_guardrail_handler.py", "python_test": "TestInputProcessing::test_process_no_document", "status": "unmapped", "reason": "scoped to the Python translation handler, not the Rust gateway's guardrail hook lifecycle"}, - {"python_file": "tests/test_litellm/llms/ocr/guardrail_translation/test_ocr_guardrail_handler.py", "python_test": "TestInputProcessing::test_process_invalid_document", "status": "unmapped", "reason": "scoped to the Python translation handler, not the Rust gateway's guardrail hook lifecycle"}, - {"python_file": "tests/test_litellm/llms/ocr/guardrail_translation/test_ocr_guardrail_handler.py", "python_test": "TestInputProcessing::test_input_blocking_guardrail", "status": "unmapped", "reason": "scoped to the Python translation handler, not the Rust gateway's guardrail hook lifecycle"}, - {"python_file": "tests/test_litellm/llms/ocr/guardrail_translation/test_ocr_guardrail_handler.py", "python_test": "TestOutputProcessing::test_process_single_page", "status": "unmapped", "reason": "scoped to the Python translation handler, not the Rust gateway's guardrail hook lifecycle"}, - {"python_file": "tests/test_litellm/llms/ocr/guardrail_translation/test_ocr_guardrail_handler.py", "python_test": "TestOutputProcessing::test_process_multiple_pages", "status": "unmapped", "reason": "scoped to the Python translation handler, not the Rust gateway's guardrail hook lifecycle"}, - {"python_file": "tests/test_litellm/llms/ocr/guardrail_translation/test_ocr_guardrail_handler.py", "python_test": "TestOutputProcessing::test_process_empty_pages", "status": "unmapped", "reason": "scoped to the Python translation handler, not the Rust gateway's guardrail hook lifecycle"}, - {"python_file": "tests/test_litellm/llms/ocr/guardrail_translation/test_ocr_guardrail_handler.py", "python_test": "TestOutputProcessing::test_process_page_with_empty_markdown", "status": "unmapped", "reason": "scoped to the Python translation handler, not the Rust gateway's guardrail hook lifecycle"}, - {"python_file": "tests/test_litellm/llms/ocr/guardrail_translation/test_ocr_guardrail_handler.py", "python_test": "TestOutputProcessing::test_process_preserves_page_metadata", "status": "unmapped", "reason": "scoped to the Python translation handler, not the Rust gateway's guardrail hook lifecycle"}, - {"python_file": "tests/test_litellm/llms/ocr/guardrail_translation/test_ocr_guardrail_handler.py", "python_test": "TestOutputProcessing::test_output_blocking_guardrail", "status": "unmapped", "reason": "scoped to the Python translation handler, not the Rust gateway's guardrail hook lifecycle"}, - {"python_file": "tests/test_litellm/llms/ocr/guardrail_translation/test_ocr_guardrail_handler.py", "python_test": "TestPIIMaskingScenario::test_pii_masking_in_ocr_pages", "status": "unmapped", "reason": "PII redaction in the translation handler has no Rust equivalent"}, - - {"python_file": "tests/test_litellm/proxy/ocr_endpoints/test_endpoints.py", "python_test": "test_should_read_req_format_from_header", "status": "unmapped", "reason": "proxy-layer header parsing has no Rust equivalent"}, - {"python_file": "tests/test_litellm/proxy/ocr_endpoints/test_endpoints.py", "python_test": "test_should_prefer_body_req_format_over_header", "status": "unmapped", "reason": "proxy-layer body-vs-header precedence has no Rust equivalent"}, - {"python_file": "tests/test_litellm/proxy/ocr_endpoints/test_endpoints.py", "python_test": "test_should_omit_req_format_when_header_absent", "status": "unmapped", "reason": "proxy-layer parsing has no Rust equivalent"}, - {"python_file": "tests/test_litellm/proxy/ocr_endpoints/test_endpoints.py", "python_test": "test_should_reject_unknown_req_format", "status": "unmapped", "reason": "proxy-layer validation has no Rust equivalent"}, - {"python_file": "tests/test_litellm/proxy/ocr_endpoints/test_endpoints.py", "python_test": "test_should_return_native_payload_with_litellm_response_headers", "status": "unmapped", "reason": "proxy-layer response construction has no Rust equivalent"}, - {"python_file": "tests/test_litellm/proxy/ocr_endpoints/test_endpoints.py", "python_test": "test_should_return_normalized_response_when_no_native_payload", "status": "unmapped", "reason": "proxy-layer response construction has no Rust equivalent"} - ], - "rust_only_tests": [ - {"rust_file": "litellm-rust/crates/core/src/providers/azure_ai/ocr/transformation.rs", "rust_test": "document_intelligence_maps_features", "reason": "Rust retains the feature list while filtering unsupported parameters; Python normalizes the list to a string during mapping"}, - {"rust_file": "litellm-rust/crates/core/src/providers/azure_ai/ocr/transformation.rs", "rust_test": "document_intelligence_url_normalizes_zero_based_pages", "reason": "Python covers ascending page indices with features, but has no dedicated test for deduplicating and sorting page indices"}, - {"rust_file": "litellm-rust/crates/ai-gateway/src/integrations/custom_logger/mod.rs", "rust_test": "rust_custom_logger_reads_failure_payload_for_non_ocr_call_type", "reason": "exercises the non-OCR (acompletion) call-type branch of the logger; the OCR branch is covered separately by rust_custom_logger_reads_success_payload_for_ocr"}, - {"rust_file": "litellm-rust/crates/ai-gateway/src/integrations/custom_logger/mod.rs", "rust_test": "no_callback_fast_path_dispatches_nothing", "reason": "Rust-only fast-path optimization test for when zero callbacks are registered; Python has no equivalent no-op dispatch path"}, - {"rust_file": "litellm-rust/crates/ai-gateway/src/integrations/custom_logger/mod.rs", "rust_test": "with_standard_logging_payload_keeps_top_level_fields_in_sync", "reason": "Rust-internal builder-method invariant, Python has no equivalent internal builder"}, - {"rust_file": "litellm-rust/crates/ai-gateway/src/ocr/common_utils.rs", "rust_test": "blocks_private_and_metadata_ips", "reason": "SSRF IP-blocking helper has no Python unit test; Python relies on the proxy-layer JSON/form guards instead"}, - {"rust_file": "litellm-rust/crates/ai-gateway/src/ocr/common_utils.rs", "rust_test": "convert_document_url_rejects_loopback_fetch", "reason": "URL-fetch SSRF protection is Rust-gateway-only"}, - {"rust_file": "litellm-rust/crates/ai-gateway/src/ocr/common_utils.rs", "rust_test": "convert_document_url_leaves_data_uri_untouched", "reason": "URL-fetch SSRF protection is Rust-gateway-only"}, - {"rust_file": "litellm-rust/crates/ai-gateway/src/ocr/tests.rs", "rust_test": "truncate_error_body_passes_short_strings_through", "reason": "Rust-gateway error-body truncation helper has no Python counterpart"}, - {"rust_file": "litellm-rust/crates/ai-gateway/src/ocr/tests.rs", "rust_test": "truncate_error_body_caps_long_payloads", "reason": "Rust-gateway error-body truncation helper has no Python counterpart"}, - {"rust_file": "litellm-rust/crates/ai-gateway/src/ocr/tests.rs", "rust_test": "truncate_error_body_does_not_split_multibyte_chars", "reason": "Rust-gateway error-body truncation helper has no Python counterpart"}, - {"rust_file": "litellm-rust/crates/ai-gateway/src/ocr/tests.rs", "rust_test": "ocr_dispatch_supports_migrated_providers", "reason": "Rust-internal provider-config dispatch table has no equivalent Python unit test"}, - {"rust_file": "litellm-rust/crates/ai-gateway/src/ocr/tests.rs", "rust_test": "string_headers_accepts_string_values", "reason": "Rust-gateway header-coercion helper has no Python counterpart"}, - {"rust_file": "litellm-rust/crates/ai-gateway/src/ocr/tests.rs", "rust_test": "auth_header_detection_is_case_insensitive", "reason": "Rust-gateway header-detection helper has no Python counterpart"}, - {"rust_file": "litellm-rust/crates/ai-gateway/src/ocr/tests.rs", "rust_test": "ocr_lifecycle_runs_pre_during_and_success_hooks", "reason": "full gateway-level guardrail-hook-plus-HTTP-lifecycle test with no Python equivalent at this integration scope"}, - {"rust_file": "litellm-rust/crates/ai-gateway/src/ocr/tests.rs", "rust_test": "ocr_lifecycle_runs_failure_hook_on_provider_error", "reason": "full gateway-level failure-hook-plus-HTTP-lifecycle test with no Python equivalent at this integration scope"}, - {"rust_file": "litellm-rust/crates/ai-gateway/src/ocr/tests.rs", "rust_test": "ocr_lifecycle_pre_call_block_skips_provider_socket", "reason": "full gateway-level pre-call-block-plus-socket-skip test with no Python equivalent at this integration scope"}, - {"rust_file": "litellm-rust/crates/ai-gateway/src/ocr/tests.rs", "rust_test": "ocr_does_not_duplicate_authorization_header_when_header_is_supplied", "reason": "outgoing HTTP header dedup at the Rust gateway has no Python counterpart"}, - {"rust_file": "litellm-rust/crates/ai-gateway/src/ocr/tests.rs", "rust_test": "document_intelligence_poll_uses_resolved_subscription_key", "reason": "full Azure DI poll-loop integration test with no Python equivalent at this scope"}, - {"rust_file": "litellm-rust/crates/ai-gateway/src/ocr/tests.rs", "rust_test": "string_headers_rejects_non_string_values", "reason": "Rust-gateway header-coercion error path has no Python counterpart"}, - {"rust_file": "litellm-rust/crates/core/src/providers/azure_ai/ocr/transformation.rs", "rust_test": "azure_ai_reuses_mistral_body_transform", "reason": "Rust-internal delegation-to-Mistral-transform implementation detail, no Python test asserts this delegation"}, - {"rust_file": "litellm-rust/crates/core/src/providers/azure_ai/ocr/transformation.rs", "rust_test": "document_intelligence_request_uses_base64_source_for_data_uri", "reason": "no Python test asserts on the base64Source request body shape"}, - {"rust_file": "litellm-rust/crates/core/src/providers/vertex_ai/ocr/transformation.rs", "rust_test": "vertex_mistral_url_uses_project_location_and_model", "reason": "vertex OCR support has no Python unit test coverage yet"}, - {"rust_file": "litellm-rust/crates/core/src/providers/vertex_ai/ocr/transformation.rs", "rust_test": "vertex_mistral_reuses_mistral_body_transform", "reason": "vertex OCR support has no Python unit test coverage yet"}, - {"rust_file": "litellm-rust/crates/core/src/providers/vertex_ai/ocr/transformation.rs", "rust_test": "vertex_deepseek_request_uses_ocr_endpoint_shape", "reason": "vertex OCR support has no Python unit test coverage yet"}, - {"rust_file": "litellm-rust/crates/core/src/providers/vertex_ai/ocr/transformation.rs", "rust_test": "vertex_deepseek_response_wraps_markdown_content", "reason": "vertex OCR support has no Python unit test coverage yet"}, - {"rust_file": "litellm-rust/crates/core/src/providers/mistral/ocr/transformation.rs", "rust_test": "transform_ocr_request_rejects_non_object_document", "reason": "non-object document rejection has no dedicated Python unit test"}, - {"rust_file": "litellm-rust/crates/core/src/providers/mistral/ocr/transformation.rs", "rust_test": "transform_ocr_response_normalizes_mistral_json", "reason": "Python's response tests target OCR4-specific fields only, none asserts the same base normalization this Rust test checks"}, - {"rust_file": "litellm-rust/crates/core/src/providers/mistral/ocr/transformation.rs", "rust_test": "complete_url_defaults_and_dedupes_v1", "reason": "URL-building/defaulting for Mistral has no Python unit test"}, - {"rust_file": "litellm-rust/crates/core/src/providers/mistral/ocr/transformation.rs", "rust_test": "resolve_api_key_prefers_param_then_env", "reason": "API key resolution precedence at the Rust provider-config layer has no Python unit test"}, - {"rust_file": "litellm-rust/crates/core/src/providers/mistral/ocr/transformation.rs", "rust_test": "resolve_api_key_errors_when_absent", "reason": "API key resolution error path at the Rust provider-config layer has no Python unit test"}, - {"rust_file": "litellm-rust/crates/ai-gateway/src/integrations/custom_logger/mod.rs", "rust_test": "rust_custom_logger_reads_success_payload_for_ocr", "reason": "Rust-internal custom-logger dispatch for OCR payloads has no Python unit test at this layer"} - ] -} diff --git a/tests/rust-python-harness/strategies/unit_tests/mapping_validator.py b/tests/rust-python-harness/strategies/unit_tests/mapping_validator.py deleted file mode 100644 index d805311e488..00000000000 --- a/tests/rust-python-harness/strategies/unit_tests/mapping_validator.py +++ /dev/null @@ -1,173 +0,0 @@ -from __future__ import annotations - -from collections import Counter -from collections.abc import Sequence -from dataclasses import dataclass -from pathlib import Path -from typing import Final - -from pydantic import BaseModel, ConfigDict - -from ...shared.parity.ledger import TestLedger, load_ledger -from .python_runner import enumerate_python_tests -from .rust_runner import enumerate_rust_tests - - -class TestMapping(BaseModel): - model_config = ConfigDict(frozen=True, extra="forbid") - - python: str - rust: str - - -@dataclass(frozen=True, slots=True) -class MappingReport: - pairs: tuple[TestMapping, ...] - problems: tuple[str, ...] - - -def _name(node: str) -> str: - return node.rsplit("::", 1)[-1].split("[", 1)[0] - - -def validate_mapping( - python_tests: Sequence[str], - rust_tests: Sequence[str], - annotations: Sequence[TestMapping] = (), -) -> MappingReport: - explicit_problems: Final = ( - *(f"missing Python counterpart: {pair.python}" for pair in annotations if pair.python not in python_tests), - *(f"missing Rust counterpart: {pair.rust}" for pair in annotations if pair.rust not in rust_tests), - *( - f"ambiguous Python annotation: {name}" - for name, count in Counter(p.python for p in annotations).items() - if count > 1 - ), - *( - f"ambiguous Rust annotation: {name}" - for name, count in Counter(p.rust for p in annotations).items() - if count > 1 - ), - ) - explicit_python: Final = {pair.python for pair in annotations} - candidates: Final = { - python: tuple(rust for rust in rust_tests if _name(python) == _name(rust)) - for python in python_tests - if python not in explicit_python - } - pairs: Final = ( - *annotations, - *(TestMapping(python=python, rust=matches[0]) for python, matches in candidates.items() if len(matches) == 1), - ) - problems: Final = ( - *explicit_problems, - *(f"missing Rust counterpart: {python}" for python, matches in candidates.items() if not matches), - *( - f"ambiguous Rust counterparts: {python}: {matches}" - for python, matches in candidates.items() - if len(matches) > 1 - ), - *( - f"ambiguous Python counterparts: {rust}" - for rust, count in Counter(pair.rust for pair in pairs).items() - if count > 1 - ), - *(f"missing Python counterpart: {rust}" for rust in rust_tests if rust not in {pair.rust for pair in pairs}), - *(("no Python tests collected",) if not python_tests else ()), - *(("no Rust tests collected",) if not rust_tests else ()), - ) - return MappingReport(pairs, problems) - - -REPO_ROOT = Path(__file__).resolve().parents[4] -LEDGER_ROOT = Path(__file__).parent / "ledgers" - - -def ledger_path_for(sdk_function: str) -> Path: - return LEDGER_ROOT / sdk_function / f"{sdk_function}_test_ledger.json" - - -@dataclass(frozen=True, slots=True) -class AuditReport: - missing_python_tests: tuple[str, ...] - stale_python_tests: tuple[str, ...] - missing_rust_tests: tuple[str, ...] - stale_rust_tests: tuple[str, ...] - - @property - def is_clean(self) -> bool: - return not ( - self.missing_python_tests - or self.stale_python_tests - or self.missing_rust_tests - or self.stale_rust_tests - ) - - -def _ledger_python_tests_by_file(ledger: TestLedger) -> dict[str, set[str]]: - grouping: dict[str, set[str]] = {path: set() for path in ledger.python_scope} - for entry in ledger.entries: - grouping.setdefault(entry.python_file, set()).add(entry.python_test) - return grouping - - -def _ledger_rust_tests_by_file(ledger: TestLedger) -> dict[str, set[str]]: - grouping: dict[str, set[str]] = {path: set() for path in ledger.rust_scope} - for entry in ledger.entries: - if entry.status == "mapped": - grouping.setdefault(entry.rust_file, set()).add(entry.rust_test) - for rust_only in ledger.rust_only_tests: - grouping.setdefault(rust_only.rust_file, set()).add(rust_only.rust_test) - return grouping - - -def audit_ledger(ledger: TestLedger, repo_root: Path = REPO_ROOT) -> AuditReport: - missing_python: list[str] = [] - stale_python: list[str] = [] - for python_file, ledger_tests in _ledger_python_tests_by_file(ledger).items(): - actual_tests = enumerate_python_tests(repo_root, python_file) - for missing in sorted(ledger_tests - actual_tests): - missing_python.append(f"{python_file}:{missing}") - for stale in sorted(actual_tests - ledger_tests): - stale_python.append(f"{python_file}:{stale}") - - missing_rust: list[str] = [] - stale_rust: list[str] = [] - for rust_file, ledger_tests in _ledger_rust_tests_by_file(ledger).items(): - actual_tests = enumerate_rust_tests(repo_root, rust_file) - for missing in sorted(ledger_tests - actual_tests): - missing_rust.append(f"{rust_file}:{missing}") - for stale in sorted(actual_tests - ledger_tests): - stale_rust.append(f"{rust_file}:{stale}") - - return AuditReport( - missing_python_tests=tuple(missing_python), - stale_python_tests=tuple(stale_python), - missing_rust_tests=tuple(missing_rust), - stale_rust_tests=tuple(stale_rust), - ) - - -@dataclass(frozen=True, slots=True) -class FunctionReport: - sdk_function: str - ledger: TestLedger | None - audit: AuditReport | None - - @property - def has_ledger(self) -> bool: - return self.ledger is not None - - @property - def is_clean(self) -> bool: - return self.audit is None or self.audit.is_clean - - -def build_function_report(sdk_function: str, repo_root: Path = REPO_ROOT) -> FunctionReport: - path = ledger_path_for(sdk_function) - if not path.exists(): - return FunctionReport(sdk_function=sdk_function, ledger=None, audit=None) - ledger = load_ledger(path) - return FunctionReport( - sdk_function=sdk_function, ledger=ledger, audit=audit_ledger(ledger, repo_root) - ) diff --git a/tests/rust-python-harness/strategies/unit_tests/runner.py b/tests/rust-python-harness/strategies/unit_tests/runner.py deleted file mode 100644 index d10fa364d1c..00000000000 --- a/tests/rust-python-harness/strategies/unit_tests/runner.py +++ /dev/null @@ -1,91 +0,0 @@ -from __future__ import annotations - -import sys -from collections.abc import Sequence -from pathlib import Path -from time import monotonic -from typing import Final - -from pydantic import BaseModel, ConfigDict - -from ...shared.reporting.models import HarnessCase, HarnessRun, RunStatus -from ...shared.reporting.pytest_runner import UpdateCallback -from .mapping_validator import TestMapping, validate_mapping -from .python_runner import BackendSpec, compare_python_runs, run_python_tests -from .rust_runner import run_rust_tests - - -class UnitSuite(BaseModel): - model_config = ConfigDict(frozen=True, extra="forbid") - - python_selectors: tuple[str, ...] - cargo_manifest: str - cargo_package: str - cargo_filter: str - backend: BackendSpec - mappings: tuple[TestMapping, ...] = () - - -def run_suite(suite: UnitSuite, repo_root: Path, pytest_args: Sequence[str] = ()) -> tuple[str, ...]: - if not suite.python_selectors or not suite.cargo_filter: - return ("unit suites must select Python tests and a focused Cargo filter",) - python: Final = run_python_tests(suite.python_selectors, repo_root, "python", suite.backend, pytest_args) - rust_python: Final = run_python_tests(suite.python_selectors, repo_root, "rust", suite.backend, pytest_args) - inventory: Final = run_rust_tests( - repo_root / suite.cargo_manifest, suite.cargo_package, suite.cargo_filter, collect_only=True - ) - mapping: Final = validate_mapping(python.tests, inventory.tests, suite.mappings) - rust: Final = run_rust_tests(repo_root / suite.cargo_manifest, suite.cargo_package, suite.cargo_filter) - return ( - *compare_python_runs(python, rust_python), - *mapping.problems, - *(("native Rust tests did not all pass",) if set(inventory.tests) != set(rust.tests) else ()), - *((inventory.output,) if inventory.exit_code else ()), - *((rust.output,) if rust.exit_code else ()), - ) - - -def run( - cases: Sequence[HarnessCase], - repo_root: Path, - on_update: UpdateCallback, - pytest_args: Sequence[str] = (), -) -> tuple[int, HarnessRun]: - report: Final = HarnessRun.from_cases(cases) - for case in cases: - result: Final = report.results[case.key] - if case.unit_suite is None: - result.finalize() - continue - nodeid: Final = f"unit-suite:{case.unit_suite}" - result.collected.add(nodeid) - result.status = RunStatus.RUNNING - on_update(report) - try: - suite: Final = UnitSuite.model_validate_json((repo_root / case.unit_suite).read_text()) - problems: Final = run_suite(suite, repo_root, pytest_args) - except (OSError, ValueError) as error: - result.record(nodeid, RunStatus.ERROR) - report.failures.append((nodeid, str(error))) - continue - result.record(nodeid, RunStatus.FAILED if problems else RunStatus.PASSED) - report.failures.extend((nodeid, problem) for problem in problems) - on_update(report) - report.finished_at = monotonic() - on_update(report) - return int( - any( - result.status in {RunStatus.ERROR, RunStatus.FAILED, RunStatus.MISSING} - for result in report.results.values() - ) - ), report - - -def main(argv: Sequence[str] | None = None) -> int: - from ...cli import main as harness_main - - return harness_main(argv, strategy_id="unit_tests") - - -if __name__ == "__main__": - raise SystemExit(main(sys.argv[1:])) diff --git a/tests/rust-python-harness/strategies/unit_tests/rust_runner.py b/tests/rust-python-harness/strategies/unit_tests/rust_runner.py deleted file mode 100644 index b24034199b5..00000000000 --- a/tests/rust-python-harness/strategies/unit_tests/rust_runner.py +++ /dev/null @@ -1,53 +0,0 @@ -from __future__ import annotations - -import re -import subprocess -from dataclasses import dataclass -from pathlib import Path -from typing import Final - - -@dataclass(frozen=True, slots=True) -class RustReport: - tests: tuple[str, ...] - exit_code: int - output: str - - -def run_rust_tests(manifest: Path, package: str, test_filter: str, *, collect_only: bool = False) -> RustReport: - command: Final = ( - "cargo", - "test", - "--manifest-path", - str(manifest), - "--package", - package, - "--lib", - test_filter, - "--", - *(("--list",) if collect_only else ("--format=pretty",)), - ) - try: - result: Final = subprocess.run(command, capture_output=True, text=True, check=False, timeout=600) - except (OSError, subprocess.TimeoutExpired) as error: - return RustReport((), 1, str(error)) - tests: Final = ( - tuple(line.removesuffix(": test") for line in result.stdout.splitlines() if line.endswith(": test")) - if collect_only - else tuple( - line.removeprefix("test ").removesuffix(" ... ok") - for line in result.stdout.splitlines() - if line.startswith("test ") and line.endswith(" ... ok") - ) - ) - return RustReport(tests, result.returncode, result.stdout + result.stderr) - - -_RUST_TEST_PATTERN = re.compile( - r"#\[(?:test|tokio::test)\][^\n]*\n(?:[^\n]*\n)*?\s*(?:async\s+)?fn\s+(\w+)\s*\(" -) - - -def enumerate_rust_tests(repo_root: Path, relative_path: str) -> frozenset[str]: - source = (repo_root / relative_path).read_text(encoding="utf-8") - return frozenset(match.group(1) for match in _RUST_TEST_PATTERN.finditer(source)) diff --git a/tests/rust-python-harness/strategies/unit_tests/strategy.json b/tests/rust-python-harness/strategies/unit_tests/strategy.json deleted file mode 100644 index 7ae5d9c22c6..00000000000 --- a/tests/rust-python-harness/strategies/unit_tests/strategy.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "order": 30, - "id": "unit_tests", - "label": "Unit tests", - "description": "Validate Python/Rust test mappings and compare isolated Python runs alongside native Cargo tests.", - "functions": { - "ocr": { - "coverage": "planned", - "selectors": [] - }, - "messages": { - "coverage": "planned", - "selectors": [] - }, - "chat_completions": { - "coverage": "planned", - "selectors": [] - }, - "responses": { - "coverage": "planned", - "selectors": [] - }, - "count_tokens": { - "coverage": "planned", - "selectors": [] - }, - "transcription": { - "coverage": "planned", - "selectors": [] - } - } -} diff --git a/tests/rust-python-harness/strategies/unit_tests/test_mapping_validator.py b/tests/rust-python-harness/strategies/unit_tests/test_mapping_validator.py deleted file mode 100644 index 25c63faf3a7..00000000000 --- a/tests/rust-python-harness/strategies/unit_tests/test_mapping_validator.py +++ /dev/null @@ -1,38 +0,0 @@ -from __future__ import annotations - -import pytest - -from .mapping_validator import TestMapping as Mapping, validate_mapping - - -def test_matches_names_and_explicit_annotations() -> None: - report = validate_mapping( - ("tests/test_api.py::test_decode", "tests/test_api.py::test_error"), - ("api::test_decode", "api::preserves_error"), - (Mapping(python="tests/test_api.py::test_error", rust="api::preserves_error"),), - ) - assert report.problems == () - assert {(pair.python, pair.rust) for pair in report.pairs} == { - ("tests/test_api.py::test_decode", "api::test_decode"), - ("tests/test_api.py::test_error", "api::preserves_error"), - } - - -@pytest.mark.parametrize( - ("python", "rust", "message"), - ( - (("test_decode",), (), "missing Rust counterpart"), - ((), ("test_decode",), "missing Python counterpart"), - (("test_decode",), ("one::test_decode", "two::test_decode"), "ambiguous Rust counterparts"), - (("one::test_decode", "two::test_decode"), ("test_decode",), "ambiguous Python counterparts"), - ), -) -def test_reports_missing_and_ambiguous_counterparts( - python: tuple[str, ...], rust: tuple[str, ...], message: str -) -> None: - assert any(message in problem for problem in validate_mapping(python, rust).problems) - - -def test_rejects_stale_annotations_even_when_names_match() -> None: - report = validate_mapping(("test_decode",), ("test_decode",), (Mapping(python="test_decode", rust="removed"),)) - assert "missing Rust counterpart: removed" in report.problems diff --git a/tests/rust-python-harness/strategies/unit_tests/test_runner.py b/tests/rust-python-harness/strategies/unit_tests/test_runner.py deleted file mode 100644 index c652d6e12b1..00000000000 --- a/tests/rust-python-harness/strategies/unit_tests/test_runner.py +++ /dev/null @@ -1,59 +0,0 @@ -from __future__ import annotations - -import json -import shutil -from pathlib import Path -from typing import Final - -import pytest - -from ...shared.reporting.models import Coverage, HarnessCase, RunStatus -from .runner import run - - -@pytest.mark.skipif(shutil.which("cargo") is None, reason="Cargo is required for the combined unit strategy") -def test_combines_mapping_backend_comparison_and_cargo_results(tmp_path: Path, monkeypatch) -> None: - monkeypatch.setenv("PYTHONPATH", str(Path(__file__).resolve().parents[4])) - monkeypatch.setenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD", "1") - (tmp_path / "pytest.ini").write_text("[pytest]\n") - (tmp_path / "backend_probe.py").write_text( - "import os\ndef selected():\n return 'rust' if os.environ['TEST_USE_RUST'] == '1' else 'python'\n" - ) - (tmp_path / "test_api.py").write_text("def test_decode():\n assert int('42') == 42\n") - (tmp_path / "Cargo.toml").write_text( - '[package]\nname = "combined-check"\nversion = "0.1.0"\nedition = "2021"\n[workspace]\n' - ) - (tmp_path / "src").mkdir() - (tmp_path / "src/lib.rs").write_text('#[test] fn test_decode() { assert_eq!("42".parse::().unwrap(), 42); }\n') - suite: Final = { - "python_selectors": ("test_api.py",), - "cargo_manifest": "Cargo.toml", - "cargo_package": "combined-check", - "cargo_filter": "test_decode", - "backend": {"environment_variable": "TEST_USE_RUST", "probe": "backend_probe:selected"}, - } - (tmp_path / "suite.json").write_text(json.dumps(suite)) - case: Final = HarnessCase( - strategy_id="unit_tests", - strategy_label="Unit tests", - sdk_function="ocr", - coverage=Coverage.COMPLETE, - selectors=(), - unit_suite="suite.json", - ) - code, report = run((case,), tmp_path, lambda _: None) - assert code == 0, report.failures - assert report.results[case.key].status is RunStatus.PASSED - (tmp_path / "suite.json").write_text( - json.dumps({**suite, "mappings": [{"python": "test_api.py::test_decode", "rust": "removed"}]}) - ) - failed_code, failed_report = run((case,), tmp_path, lambda _: None) - assert failed_code == 1 - assert failed_report.results[case.key].status is RunStatus.FAILED - assert any("missing Rust counterpart: removed" in detail for _, detail in failed_report.failures) - - (tmp_path / "suite.json").write_text(json.dumps(suite)) - (tmp_path / "src/lib.rs").write_text("#[test] #[ignore] fn test_decode() {}\n") - skipped_code, skipped_report = run((case,), tmp_path, lambda _: None) - assert skipped_code == 1 - assert any("native Rust tests did not all pass" in detail for _, detail in skipped_report.failures) diff --git a/tests/rust-python-harness/strategies/unit_tests/test_rust_runner.py b/tests/rust-python-harness/strategies/unit_tests/test_rust_runner.py deleted file mode 100644 index aeeb7f602f5..00000000000 --- a/tests/rust-python-harness/strategies/unit_tests/test_rust_runner.py +++ /dev/null @@ -1,27 +0,0 @@ -from __future__ import annotations - -import shutil -from pathlib import Path -from typing import Final - -import pytest - -from .rust_runner import run_rust_tests - - -@pytest.mark.skipif(shutil.which("cargo") is None, reason="Cargo is required for native runner integration") -def test_collects_and_runs_native_tests_and_propagates_failure(tmp_path: Path) -> None: - manifest: Final = tmp_path / "Cargo.toml" - manifest.write_text('[package]\nname = "harness-runner-check"\nversion = "0.1.0"\nedition = "2021"\n[workspace]\n') - (tmp_path / "src").mkdir() - source: Final = tmp_path / "src/lib.rs" - source.write_text("#[test] fn test_parity() { assert_eq!(2 + 2, 4); }\n") - inventory: Final = run_rust_tests(manifest, "harness-runner-check", "test_parity", collect_only=True) - assert inventory.exit_code == 0, inventory.output - assert inventory.tests == ("test_parity",) - passing: Final = run_rust_tests(manifest, "harness-runner-check", "test_parity") - assert passing.exit_code == 0, passing.output - source.write_text("#[test] fn test_parity() { assert_eq!(2 + 2, 5); }\n") - failed: Final = run_rust_tests(manifest, "harness-runner-check", "test_parity") - assert failed.exit_code != 0 - assert "test_parity" in failed.output diff --git a/tests/rust-python-harness/strategies/unit_tests_mapping/AGENTS.md b/tests/rust-python-harness/strategies/unit_tests_mapping/AGENTS.md new file mode 100644 index 00000000000..5b389dc9d1b --- /dev/null +++ b/tests/rust-python-harness/strategies/unit_tests_mapping/AGENTS.md @@ -0,0 +1,13 @@ +# What this is + +Validates that unit tests covering traced Python behavior have semantic counterparts among colocated Rust unit tests + +# How it works + +Trace parity runs representative public API scenarios and records the Python and Rust functions reached, including their source files and lines. The OCR contract selects the behavior-level trace spans that require parity and excludes shared infrastructure such as generic HTTP transport + +For Python, those traced functions define the denominator. Static references and explicit includes create a safe pytest discovery universe, then a pytest profiler keeps only tests that actually execute at least one selected function. Static matches do not count by themselves. Parametrized pytest cases are collapsed to one logical test function in the mapping report. Explicit includes and exclusions cover dynamic callers or intentional harness behavior that static discovery cannot express reliably + +For Rust, each traced function identifies its source file and module. If that source file has a colocated `#[cfg(test)] mod tests`, the harness inventories that module for the configured Rust target. Rust test names are therefore derived from traced implementation files, not from a hand-maintained list of OCR test modules + +The Python-to-Rust mappings remain explicit because equivalent behavior often has different test boundaries and names in each SDK. The report validates those mappings against both live inventories, then shows mapped Python tests, unmapped Python tests that still need a Rust counterpart, and Rust-only tests diff --git a/tests/rust-python-harness/strategies/unit_tests_mapping/__init__.py b/tests/rust-python-harness/strategies/unit_tests_mapping/__init__.py new file mode 100644 index 00000000000..4d857c01ed0 --- /dev/null +++ b/tests/rust-python-harness/strategies/unit_tests_mapping/__init__.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from functools import partial +from pathlib import Path +from typing import Final + +from ...shared.reporting.models import SDK_FUNCTIONS, Coverage +from ...shared.reporting.strategy import ( + CaseDefinition, + NotImplementedCaseSpec, + RunnerArgumentDefinition, + StrategyDefinition, + SuiteCaseSpec, +) +from ...shared.unit_runners.suite_runner import run_suites +from .mappings import UNIT_TEST_CONTRACTS +from .reporting import render_mapping_results +from .runner import run_suite + + +CASES: Final[tuple[CaseDefinition, ...]] = ( + *( + CaseDefinition( + sdk_function, + SuiteCaseSpec(coverage=Coverage.COMPLETE, suite=sdk_function) + if sdk_function in UNIT_TEST_CONTRACTS + else NotImplementedCaseSpec(reason=f"No {sdk_function} unit-test mapping is registered."), + ) + for sdk_function in SDK_FUNCTIONS + ), +) + +STRATEGY: Final = StrategyDefinition( + id="unit_tests_mapping", + order=30, + label="Unit test mapping", + description="Validate Python/Rust unit-test mappings against collected test inventories.", + directory=Path(__file__).parent, + runnable_spec=SuiteCaseSpec, + cases=CASES, + run=partial(run_suites, suites=UNIT_TEST_CONTRACTS, execute=run_suite), + render=render_mapping_results, + runner_argument=RunnerArgumentDefinition( + option="--detail", + metavar="MODE", + help="show individual test names; any value enables full detail", + ), +) diff --git a/tests/rust-python-harness/strategies/unit_tests_mapping/cases/__init__.py b/tests/rust-python-harness/strategies/unit_tests_mapping/cases/__init__.py new file mode 100644 index 00000000000..8b137891791 --- /dev/null +++ b/tests/rust-python-harness/strategies/unit_tests_mapping/cases/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/rust-python-harness/strategies/unit_tests_mapping/cases/ocr.py b/tests/rust-python-harness/strategies/unit_tests_mapping/cases/ocr.py new file mode 100644 index 00000000000..0599539d314 --- /dev/null +++ b/tests/rust-python-harness/strategies/unit_tests_mapping/cases/ocr.py @@ -0,0 +1,168 @@ +from __future__ import annotations + +from typing import Final + +from ....shared.unit_runners.rust_runner import RustTarget, RustTestIdentity +from ..contracts import ( + MappingSpec, + PythonFunctionDiscoverySpec, + RustUnitSpec, + TestMapping, + UnitParityExclusionSpec, + UnitParitySpec, + UnitTestContract, +) + +_CORE_TARGET: Final = RustTarget(package="litellm-core", name="litellm_core", kind="lib") +_GATEWAY_TARGET: Final = RustTarget( + package="litellm-ai-gateway", + name="litellm_ai_gateway", + kind="lib", +) +_AZURE_OCR_TESTS: Final = "providers::azure_ai::ocr::transformation::tests" +_MISTRAL_OCR_TESTS: Final = "providers::mistral::ocr::transformation::tests" + + +def _rust_test(target: RustTarget, module: str, test: str) -> RustTestIdentity: + return RustTestIdentity(target=target, name=f"{module}::{test}") + + +OCR_CONTRACT: Final = UnitTestContract( + mapping=MappingSpec( + python_functions=PythonFunctionDiscoverySpec( + trace_module="tests.rust-python-harness.strategies.trace_parity.sdk.ocr.case", + trace_spans=( + "ocr", + "prepare_ocr_call", + "ocr_provider_config", + "supported_ocr_params", + "map_ocr_params", + "validate_environment", + "complete_url", + "transform_ocr_request", + "execute_ocr_provider_call", + "transform_ocr_response", + "poll_document_intelligence", + ), + search_roots=("tests",), + exclude_roots=( + "tests/e2e", + "tests/ocr_tests/test_ocr_mistral.py", + "tests/rust-python-harness", + ), + includes=( + "tests/test_litellm/llms/azure_ai/test_azure_document_intelligence_ocr_transformation.py", + "tests/test_litellm/llms/mistral/ocr", + "tests/test_litellm/llms/ocr", + "tests/test_litellm/ocr", + "tests/test_litellm/proxy/ocr_endpoints", + ), + exclusions=( + "tests/ocr_tests/test_ocr_azure_document_intelligence.py::TestAzureDocumentIntelligenceOCR", + "tests/ocr_tests/test_ocr_vertex_ai.py::TestVertexAIMistralOCR", + "tests/ocr_tests/test_ocr_vertex_ai.py::TestVertexAIDeepSeekOCR", + ), + ), + rust_targets=(_CORE_TARGET, _GATEWAY_TARGET), + mappings=( + TestMapping( + python="tests/test_litellm/llms/azure_ai/test_azure_document_intelligence_ocr_transformation.py::test_transform_ocr_response_preserves_azure_native_fields", + rust=_rust_test(_CORE_TARGET, _AZURE_OCR_TESTS, "document_intelligence_response_normalizes_pages"), + ), + TestMapping( + python="tests/test_litellm/llms/azure_ai/test_azure_document_intelligence_ocr_transformation.py::test_map_ocr_params_features", + rust=_rust_test(_CORE_TARGET, _AZURE_OCR_TESTS, "document_intelligence_maps_features"), + ), + TestMapping( + python="tests/test_litellm/llms/azure_ai/test_azure_document_intelligence_ocr_transformation.py::test_map_ocr_params_empty_features_list_omitted", + rust=_rust_test(_CORE_TARGET, _AZURE_OCR_TESTS, "document_intelligence_url_omits_empty_feature_list"), + ), + TestMapping( + python="tests/test_litellm/llms/azure_ai/test_azure_document_intelligence_ocr_transformation.py::test_map_ocr_params_invalid_features_raises", + rust=_rust_test(_CORE_TARGET, _AZURE_OCR_TESTS, "document_intelligence_url_rejects_invalid_features"), + ), + TestMapping( + python="tests/test_litellm/llms/azure_ai/test_azure_document_intelligence_ocr_transformation.py::test_get_complete_url_appends_features_query", + rust=_rust_test(_CORE_TARGET, _AZURE_OCR_TESTS, "document_intelligence_url_normalizes_features"), + ), + TestMapping( + python="tests/test_litellm/llms/azure_ai/test_azure_document_intelligence_ocr_transformation.py::test_get_complete_url_combines_pages_and_features", + rust=_rust_test( + _CORE_TARGET, _AZURE_OCR_TESTS, "document_intelligence_url_combines_pages_and_feature_list" + ), + ), + TestMapping( + python="tests/test_litellm/llms/mistral/ocr/test_mistral_ocr_transformation.py::TestGetSupportedOcrParams::test_extract_header_in_supported_params", + rust=_rust_test(_CORE_TARGET, _MISTRAL_OCR_TESTS, "extract_header_is_a_supported_ocr_param"), + ), + TestMapping( + python="tests/test_litellm/llms/mistral/ocr/test_mistral_ocr_transformation.py::TestGetSupportedOcrParams::test_extract_footer_in_supported_params", + rust=_rust_test(_CORE_TARGET, _MISTRAL_OCR_TESTS, "extract_footer_is_a_supported_ocr_param"), + ), + TestMapping( + python="tests/test_litellm/llms/mistral/ocr/test_mistral_ocr_transformation.py::TestGetSupportedOcrParams::test_existing_params_still_present", + rust=_rust_test(_CORE_TARGET, _MISTRAL_OCR_TESTS, "existing_ocr_params_remain_supported"), + ), + TestMapping( + python="tests/test_litellm/llms/mistral/ocr/test_mistral_ocr_transformation.py::TestMapOcrParams::test_extract_header_passed_through", + rust=_rust_test(_CORE_TARGET, _MISTRAL_OCR_TESTS, "map_ocr_params_forwards_extract_header"), + ), + TestMapping( + python="tests/test_litellm/llms/mistral/ocr/test_mistral_ocr_transformation.py::TestMapOcrParams::test_extract_footer_passed_through", + rust=_rust_test(_CORE_TARGET, _MISTRAL_OCR_TESTS, "map_ocr_params_forwards_extract_footer"), + ), + TestMapping( + python="tests/test_litellm/llms/mistral/ocr/test_mistral_ocr_transformation.py::TestMapOcrParams::test_extract_header_and_footer_together", + rust=_rust_test(_CORE_TARGET, _MISTRAL_OCR_TESTS, "map_ocr_params_forwards_extract_header_and_footer"), + ), + TestMapping( + python="tests/test_litellm/llms/mistral/ocr/test_mistral_ocr_transformation.py::TestMapOcrParams::test_unknown_param_is_dropped", + rust=_rust_test(_CORE_TARGET, _MISTRAL_OCR_TESTS, "map_ocr_params_drops_unknown_params"), + ), + TestMapping( + python="tests/test_litellm/llms/mistral/ocr/test_mistral_ocr_transformation.py::TestNewSupportedParams::test_new_param_in_supported_list", + rust=_rust_test(_CORE_TARGET, _MISTRAL_OCR_TESTS, "new_ocr_params_are_supported"), + ), + TestMapping( + python="tests/test_litellm/llms/mistral/ocr/test_mistral_ocr_transformation.py::TestNewParamsMapOcr::test_new_param_passed_through", + rust=_rust_test(_CORE_TARGET, _MISTRAL_OCR_TESTS, "map_ocr_params_forwards_new_ocr_params"), + ), + TestMapping( + python="tests/test_litellm/llms/mistral/ocr/test_mistral_ocr_transformation.py::TestTransformOcrRequest::test_param_included_in_request_body", + rust=_rust_test(_CORE_TARGET, _MISTRAL_OCR_TESTS, "transform_ocr_request_includes_each_optional_param"), + ), + TestMapping( + python="tests/test_litellm/llms/mistral/ocr/test_mistral_ocr_transformation.py::TestTransformOcrRequest::test_multiple_new_params_together", + rust=_rust_test(_CORE_TARGET, _MISTRAL_OCR_TESTS, "transform_ocr_request_includes_multiple_new_params"), + ), + TestMapping( + python="tests/test_litellm/llms/mistral/ocr/test_mistral_ocr_transformation.py::TestTransformOcrResponseOcr4Fields::test_blocks_and_confidence_scores_preserved", + rust=_rust_test( + _CORE_TARGET, _MISTRAL_OCR_TESTS, "transform_ocr_response_preserves_blocks_and_confidence_scores" + ), + ), + TestMapping( + python="tests/test_litellm/llms/mistral/ocr/test_mistral_ocr_transformation.py::TestTransformOcrResponseOcr4Fields::test_ocr4_fields_survive_model_dump", + rust=_rust_test(_CORE_TARGET, _MISTRAL_OCR_TESTS, "transform_ocr_response_preserves_ocr4_page_fields"), + ), + ), + ), + unit_parity=UnitParitySpec( + python_selectors=( + "tests/test_litellm/llms/azure_ai/test_azure_document_intelligence_ocr_transformation.py", + "tests/test_litellm/llms/mistral/ocr", + "tests/test_litellm/llms/ocr", + "tests/test_litellm/ocr", + ), + exclusions=( + UnitParityExclusionSpec( + nodeid="tests/test_litellm/ocr/test_rust_bridge.py::test_use_litellm_rust_toggles_flag", + reason="This test asserts the process-level backend flag selected by the parity runner.", + ), + ), + ), + rust=RustUnitSpec( + cargo_manifest="litellm-rust/Cargo.toml", + cargo_filter="ocr", + ), +) diff --git a/tests/rust-python-harness/strategies/unit_tests_mapping/contracts.py b/tests/rust-python-harness/strategies/unit_tests_mapping/contracts.py new file mode 100644 index 00000000000..a8f309cc8f3 --- /dev/null +++ b/tests/rust-python-harness/strategies/unit_tests_mapping/contracts.py @@ -0,0 +1,220 @@ +from __future__ import annotations + +from collections import Counter +from typing import Final, Literal + +from pydantic import BaseModel, ConfigDict, field_validator, model_validator +from typing_extensions import Self + +from ...shared.tracing.pytest_usage import PythonFunctionReference +from ...shared.unit_runners.rust_runner import RustTarget, RustTestIdentity, RustTestScope + + +class _ContractModel(BaseModel): + model_config = ConfigDict(frozen=True, extra="forbid") + + +def _clean_unique(values: tuple[str, ...], field: str) -> tuple[str, ...]: + cleaned: Final = tuple(value.strip().rstrip("/") for value in values) + if not cleaned or any(not value for value in cleaned): + raise ValueError(f"{field} must contain non-empty paths") + duplicates: Final = tuple(value for value, count in Counter(cleaned).items() if count > 1) + if duplicates: + raise ValueError(f"{field} contains duplicates: {sorted(duplicates)}") + return cleaned + + +def _selector_contains(parent: str, child: str) -> bool: + return child == parent or child.startswith(f"{parent}/") + + +class RustTestFamily(_ContractModel): + kind: Literal["family"] = "family" + target: RustTarget + name: str + + @field_validator("name") + @classmethod + def validate_name(cls, value: str) -> str: + stripped: Final = value.strip() + if not stripped or stripped.endswith("::"): + raise ValueError("must be a non-empty Rust test base name") + return stripped + + @property + def key(self) -> str: + return f"{self.target.key}::{self.name}::case_*" + + def contains(self, identity: RustTestIdentity) -> bool: + return identity.target == self.target and identity.name.startswith(f"{self.name}::case_") + + +class TestMapping(_ContractModel): + python: str + rust: RustTestIdentity | RustTestFamily + + @field_validator("python") + @classmethod + def validate_python_nodeid(cls, value: str) -> str: + stripped: Final = value.strip() + if "::" not in stripped: + raise ValueError("must be a source path and test name separated by '::'") + return stripped + + +class PythonFunctionDiscoverySpec(_ContractModel): + functions: tuple[PythonFunctionReference, ...] = () + trace_module: str | None = None + trace_spans: tuple[str, ...] = () + search_roots: tuple[str, ...] + exclude_roots: tuple[str, ...] = () + includes: tuple[str, ...] = () + exclusions: tuple[str, ...] = () + + @field_validator("search_roots") + @classmethod + def validate_search_roots(cls, value: tuple[str, ...]) -> tuple[str, ...]: + return _clean_unique(value, "python function search_roots") + + @field_validator("exclude_roots") + @classmethod + def validate_exclude_roots(cls, value: tuple[str, ...]) -> tuple[str, ...]: + if not value: + return () + return _clean_unique(value, "python function exclude_roots") + + @model_validator(mode="after") + def validate_functions(self) -> Self: + if bool(self.functions) == bool(self.trace_module): + raise ValueError("python function discovery needs exactly one function list or trace module") + if self.trace_module is not None and not self.trace_spans: + raise ValueError("trace-derived Python function discovery needs trace_spans") + if not self.functions: + return self + keys: Final = tuple(f"{function.module}:{function.qualname}" for function in self.functions) + duplicates: Final = tuple(key for key, count in Counter(keys).items() if count > 1) + if duplicates: + raise ValueError(f"python function discovery contains duplicates: {sorted(duplicates)}") + return self + + +class UnitParityExclusionSpec(_ContractModel): + nodeid: str + reason: str + + @field_validator("nodeid", "reason") + @classmethod + def validate_fields(cls, value: str) -> str: + stripped: Final = value.strip() + if not stripped: + raise ValueError("must be a non-empty string") + return stripped + + +class MappingExclusionSpec(_ContractModel): + nodeid: str + reason: str + + @field_validator("nodeid", "reason") + @classmethod + def validate_fields(cls, value: str) -> str: + stripped: Final = value.strip() + if not stripped: + raise ValueError("must be a non-empty string") + return stripped + + +class MappingSpec(_ContractModel): + python_selectors: tuple[str, ...] = () + python_functions: PythonFunctionDiscoverySpec | None = None + rust_scope: tuple[RustTestScope, ...] = () + rust_targets: tuple[RustTarget, ...] = () + mappings: tuple[TestMapping, ...] + exclusions: tuple[MappingExclusionSpec, ...] = () + require_complete: bool = False + + @field_validator("python_selectors") + @classmethod + def validate_python_selectors(cls, value: tuple[str, ...]) -> tuple[str, ...]: + if not value: + return () + return _clean_unique(value, "python_selectors") + + @model_validator(mode="after") + def validate_rust_scope(self) -> Self: + if bool(self.python_selectors) == bool(self.python_functions): + raise ValueError("mapping needs exactly one Python selector or function-discovery scope") + targets: Final = tuple(scope.target.key for scope in self.rust_scope) + duplicates: Final = tuple(target for target, count in Counter(targets).items() if count > 1) + if duplicates: + raise ValueError(f"rust_scope contains duplicate targets: {sorted(duplicates)}") + target_names: Final = tuple(target.name for target in self.rust_targets) + duplicate_names: Final = tuple(name for name, count in Counter(target_names).items() if count > 1) + if duplicate_names: + raise ValueError(f"rust_targets contains duplicate names: {sorted(duplicate_names)}") + exclusion_nodeids: Final = tuple(exclusion.nodeid for exclusion in self.exclusions) + duplicate_exclusions: Final = tuple(nodeid for nodeid, count in Counter(exclusion_nodeids).items() if count > 1) + if duplicate_exclusions: + raise ValueError(f"mapping exclusions contain duplicate nodeids: {sorted(duplicate_exclusions)}") + return self + + +class UnitParitySpec(_ContractModel): + python_selectors: tuple[str, ...] + exclusions: tuple[UnitParityExclusionSpec, ...] = () + + @field_validator("python_selectors") + @classmethod + def validate_python_selectors(cls, value: tuple[str, ...]) -> tuple[str, ...]: + return _clean_unique(value, "unit parity python_selectors") + + @model_validator(mode="after") + def validate_exclusions(self) -> Self: + nodeids: Final = tuple(exclusion.nodeid for exclusion in self.exclusions) + duplicates: Final = tuple(nodeid for nodeid, count in Counter(nodeids).items() if count > 1) + if duplicates: + raise ValueError(f"unit parity exclusions contain duplicate nodeids: {sorted(duplicates)}") + return self + + +class RustUnitSpec(_ContractModel): + cargo_manifest: str + cargo_filter: str + cargo_package: str | None = None + + @field_validator("cargo_manifest", "cargo_filter") + @classmethod + def validate_required_fields(cls, value: str) -> str: + stripped: Final = value.strip() + if not stripped: + raise ValueError("must be a non-empty string") + return stripped + + @field_validator("cargo_package") + @classmethod + def validate_package(cls, value: str | None) -> str | None: + if value is None: + return None + stripped: Final = value.strip() + if not stripped: + raise ValueError("must be a non-empty string when provided") + return stripped + + +class UnitTestContract(_ContractModel): + mapping: MappingSpec + unit_parity: UnitParitySpec + rust: RustUnitSpec + + @model_validator(mode="after") + def validate_unit_parity_scope(self) -> Self: + if not self.mapping.python_selectors: + return self + unknown: Final = tuple( + selector + for selector in self.unit_parity.python_selectors + if not any(_selector_contains(parent, selector) for parent in self.mapping.python_selectors) + ) + if unknown: + raise ValueError(f"unit parity selectors must be contained in mapping selectors: {sorted(unknown)}") + return self diff --git a/tests/rust-python-harness/strategies/unit_tests_mapping/mapping_report.py b/tests/rust-python-harness/strategies/unit_tests_mapping/mapping_report.py new file mode 100644 index 00000000000..a5fd92e449d --- /dev/null +++ b/tests/rust-python-harness/strategies/unit_tests_mapping/mapping_report.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +from collections import Counter +from collections.abc import Callable, Sequence +from typing import Final + +from pydantic import BaseModel, ConfigDict + +from .mapping_validator import MappingReport + + +class MappingReportArtifact(BaseModel): + model_config = ConfigDict(frozen=True, extra="forbid") + + report: MappingReport + detailed: bool = False + + +def _group_counts(nodeids: Sequence[str], owner: Callable[[str], str]) -> tuple[str, ...]: + counts: Final = Counter(owner(nodeid) for nodeid in nodeids) + width: Final = max((len(str(count)) for count in counts.values()), default=1) + return tuple( + f" {count:>{width}} {name}" for name, count in sorted(counts.items(), key=lambda item: (-item[1], item[0])) + ) + + +def _python_file(nodeid: str) -> str: + return nodeid.partition("::")[0] + + +def _rust_module(nodeid: str) -> str: + return nodeid.rpartition("::")[0] + + +def _details(nodeids: Sequence[str], owner: Callable[[str], str]) -> tuple[str, ...]: + owners: Final = tuple(sorted(frozenset(owner(nodeid) for nodeid in nodeids))) + return tuple( + line + for name in owners + for line in ( + f" {name}", + *(f" {nodeid.removeprefix(f'{name}::')}" for nodeid in nodeids if owner(nodeid) == name), + ) + ) + + +def _contract_errors(report: MappingReport) -> tuple[str, ...]: + return ( + *(f" Missing Python test: {nodeid}" for nodeid in report.missing_python_tests), + *(f" Missing Rust test: {nodeid}" for nodeid in report.missing_rust_tests), + *(f" Python test mapped more than once: {nodeid}" for nodeid in report.duplicate_python_mappings), + *(f" Rust test mapped more than once: {nodeid}" for nodeid in report.duplicate_rust_mappings), + *(f" Missing mapping exclusion: {nodeid}" for nodeid in report.invalid_mapping_exclusions), + *(f" Python test is both mapped and excluded: {nodeid}" for nodeid in report.mapped_and_excluded_python_tests), + *(f" Missing unit-parity exclusion: {nodeid}" for nodeid in report.invalid_unit_parity_exclusions), + ) + + +def mapping_report_lines(report: MappingReport, *, detailed: bool = False) -> tuple[str, ...]: + unmapped_count: Final = len(report.unmapped_python_tests) + excluded_count: Final = len(report.excluded_python_tests) + excluded_percentage: Final = ( + 0.0 if not report.total_count else round(100.0 * excluded_count / report.total_count, 1) + ) + unmapped_percentage: Final = ( + 0.0 if not report.total_count else round(100.0 * unmapped_count / report.total_count, 1) + ) + rust_total: Final = len(report.rust_tests) + rust_only_count: Final = len(report.rust_only_tests) + rust_mapped_count: Final = rust_total - rust_only_count + contract_errors: Final = _contract_errors(report) + detail_lines: Final = ( + ( + "", + "Unmapped Python test details", + *_details(report.unmapped_python_tests, _python_file), + "", + "Excluded Python test details", + *_details(report.excluded_python_tests, _python_file), + "", + "Rust-only test details", + *_details(report.rust_only_tests, _rust_module), + ) + if detailed + else () + ) + return ( + f"Contract: {'PASS' if report.is_valid else 'FAIL'}", + "", + "Python coverage", + f" Mapped {report.mapped_count:>3} / {report.total_count} ({report.percentage}%)", + f" Excluded {excluded_count:>3} / {report.total_count} ({excluded_percentage}%)", + f" Unmapped {unmapped_count:>3} / {report.total_count} ({unmapped_percentage}%)", + "", + "Rust inventory", + f" Mapped {rust_mapped_count:>3} / {rust_total}", + f" Rust-only {rust_only_count:>3} / {rust_total}", + "", + f"Unmapped Python tests by file ({unmapped_count})", + *_group_counts(report.unmapped_python_tests, _python_file), + "", + f"Excluded Python tests by file ({excluded_count})", + *_group_counts(report.excluded_python_tests, _python_file), + "", + f"Rust-only tests by module ({rust_only_count})", + *_group_counts(report.rust_only_tests, _rust_module), + *(("", "Contract errors", *contract_errors) if contract_errors else ()), + *detail_lines, + ) diff --git a/tests/rust-python-harness/strategies/unit_tests_mapping/mapping_validator.py b/tests/rust-python-harness/strategies/unit_tests_mapping/mapping_validator.py new file mode 100644 index 00000000000..98ea0b02e68 --- /dev/null +++ b/tests/rust-python-harness/strategies/unit_tests_mapping/mapping_validator.py @@ -0,0 +1,298 @@ +from __future__ import annotations + +import importlib +from collections import Counter, defaultdict +from collections.abc import Callable, Sequence +from pathlib import Path +from typing import Final, TypeAlias + +from pydantic import BaseModel, ConfigDict + +from ...shared.tracing.pytest_usage import ( + PythonFunctionIdentity, + RustFunctionIdentity, + candidate_test_files, + collect_python_function_tests, +) +from ...shared.tracing.steps import pipeline_projection +from ...shared.unit_runners.python_runner import collect_python_tests, contract_nodeid +from ...shared.unit_runners.rust_runner import RustTarget, RustTestIdentity, RustTestScope, enumerate_rust_tests +from .contracts import PythonFunctionDiscoverySpec, RustTestFamily, TestMapping, UnitTestContract + +PythonInventory: TypeAlias = Callable[[Sequence[str], Path], frozenset[str]] +RustInventory: TypeAlias = Callable[[Path, tuple[RustTestScope, ...]], frozenset[RustTestIdentity]] + + +def _trace_functions( + spec: PythonFunctionDiscoverySpec, +) -> tuple[tuple[PythonFunctionIdentity, ...], tuple[RustFunctionIdentity, ...]]: + from ..trace_parity.models import RouteSpec, TraceExecutionFailure, TraceSuite + from ..trace_parity.sdk.execution import collect_trace + + if spec.trace_module is None: + return () + module: Final = importlib.import_module(spec.trace_module) + suite: Final = getattr(module, "TRACE_SUITE", None) + if not isinstance(suite, TraceSuite) or not isinstance(suite.route, RouteSpec): + raise ValueError(f"{spec.trace_module} must export an SDK TRACE_SUITE") + python_functions: Final[dict[str, PythonFunctionIdentity]] = {} + rust_functions: Final[dict[str, RustFunctionIdentity]] = {} + for scenario in suite.scenarios: + for mode in scenario.modes: + route: Final = RouteSpec( + route=suite.route.route, + python_entrypoints=suite.route.python_entrypoints, + rust_entrypoints=suite.route.rust_entrypoints, + fixture=scenario.fixture, + ) + python_trace: Final = collect_trace(route, "python", asynchronous=mode == "async") + rust_trace: Final = collect_trace(route, "rust", asynchronous=mode == "async") + if isinstance(python_trace, TraceExecutionFailure): + raise ValueError(f"Python trace discovery failed for {scenario.name}/{mode}: {python_trace.message}") + if isinstance(rust_trace, TraceExecutionFailure): + raise ValueError(f"Rust trace discovery failed for {scenario.name}/{mode}: {rust_trace.message}") + mappings: Final = scenario.mappings_for(mode) + python_projection: Final = pipeline_projection("python", python_trace, mappings) + rust_projection: Final = pipeline_projection("rust", rust_trace, mappings) + for step in python_projection.steps: + if step.span in spec.trace_spans: + function: Final = PythonFunctionIdentity.from_trace(step.raw) + python_functions[function.raw] = function + for step in rust_projection.steps: + if step.span in spec.trace_spans: + function: Final = RustFunctionIdentity.from_trace(step.raw) + rust_functions[step.raw] = function + if not python_functions or not rust_functions: + raise ValueError(f"Python trace discovery found no functions for spans: {', '.join(spec.trace_spans)}") + return ( + tuple(python_functions[key] for key in sorted(python_functions)), + tuple(rust_functions[key] for key in sorted(rust_functions)), + ) + + +def collect_python_function_inventory( + spec: PythonFunctionDiscoverySpec, + repo_root: Path, + traced_functions: Sequence[PythonFunctionIdentity] = (), +) -> frozenset[str]: + source_root: Final = repo_root / "litellm" + functions: Final = ( + tuple(reference.resolve(source_root) for reference in spec.functions) + if spec.functions + else tuple(traced_functions) + ) + discovered: Final = candidate_test_files( + functions, + spec.search_roots, + repo_root, + exclude_roots=spec.exclude_roots, + ) + selectors: Final = tuple(dict.fromkeys((*discovered, *spec.includes))) + if not selectors: + raise ValueError("Python function discovery found no candidate test files") + report: Final = collect_python_function_tests( + functions, + selectors, + repo_root, + source_root=source_root, + exclusions=spec.exclusions, + ) + if report.exit_code or report.problems: + details: Final = "\n".join(report.problems) or f"pytest exited with code {report.exit_code}" + raise ValueError(f"Python function test discovery failed:\n{details}") + return frozenset(contract_nodeid(nodeid) for usage in report.usages for nodeid in usage.tests) + + +def _colocated_rust_scope(mappings: Sequence[TestMapping]) -> tuple[RustTestScope, ...]: + modules_by_target: Final[dict[str, set[str]]] = defaultdict(set) + targets: Final[dict[str, RustTarget]] = {} + for item in mappings: + module, separator, _ = item.rust.name.partition("::tests::") + if not separator: + raise ValueError(f"Rust unit test is not colocated in a tests module: {item.rust.key}") + target_key: Final = item.rust.target.key + targets[target_key] = item.rust.target + modules_by_target[target_key].add(f"{module}::tests") + return tuple( + RustTestScope( + target=targets[target_key], + modules=tuple(sorted(modules_by_target[target_key])), + ) + for target_key in sorted(targets) + ) + + +def _traced_rust_scope( + functions: Sequence[RustFunctionIdentity], + targets: Sequence[RustTarget], + repo_root: Path, +) -> tuple[RustTestScope, ...]: + targets_by_name: Final = {target.name: target for target in targets} + modules_by_target: Final[dict[str, set[str]]] = defaultdict(set) + for function in functions: + crate: Final = function.module_path.partition("::")[0] + target: Final = targets_by_name.get(crate) + if target is None: + continue + source_candidates: Final = ( + repo_root / "litellm-rust" / function.file, + repo_root / function.file, + ) + source: Final = next((path for path in source_candidates if path.is_file()), None) + if source is None: + raise ValueError(f"Traced Rust source does not exist: {function.file}") + contents: Final = source.read_text() + if "mod tests" in contents and "#[cfg(test)]" in contents: + modules_by_target[target.key].add(function.test_module) + selected_targets: Final = {target.key: target for target in targets} + scopes: Final = tuple( + RustTestScope(target=selected_targets[key], modules=tuple(sorted(modules))) + for key, modules in sorted(modules_by_target.items()) + if modules + ) + if not scopes: + raise ValueError("Traced Rust functions have no colocated test modules") + return scopes + + +def _merge_rust_scopes(scopes: Sequence[RustTestScope]) -> tuple[RustTestScope, ...]: + targets: Final = {scope.target.key: scope.target for scope in scopes} + modules: Final[dict[str, set[str]]] = defaultdict(set) + features: Final[dict[str, set[str]]] = defaultdict(set) + default_features: Final[dict[str, bool]] = {} + for scope in scopes: + modules[scope.target.key].update(scope.modules) + features[scope.target.key].update(scope.features) + default_features[scope.target.key] = default_features.get(scope.target.key, True) and scope.default_features + return tuple( + RustTestScope( + target=targets[key], + modules=tuple( + sorted( + module + for module in modules[key] + if not any(module.startswith(f"{parent}::") for parent in modules[key]) + ) + ), + features=tuple(sorted(features[key])), + default_features=default_features[key], + ) + for key in sorted(targets) + ) + + +def _owned_rust_tests( + rust: RustTestIdentity | RustTestFamily, + inventory: frozenset[RustTestIdentity], +) -> frozenset[RustTestIdentity]: + if isinstance(rust, RustTestFamily): + return frozenset(identity for identity in inventory if rust.contains(identity)) + return frozenset((rust,)) if rust in inventory else frozenset() + + +class MappingReport(BaseModel): + model_config = ConfigDict(frozen=True, extra="forbid") + + python_tests: tuple[str, ...] + rust_tests: tuple[str, ...] + mapped_python_tests: tuple[str, ...] + excluded_python_tests: tuple[str, ...] + unmapped_python_tests: tuple[str, ...] + rust_only_tests: tuple[str, ...] + missing_python_tests: tuple[str, ...] + missing_rust_tests: tuple[str, ...] + duplicate_python_mappings: tuple[str, ...] + duplicate_rust_mappings: tuple[str, ...] + invalid_mapping_exclusions: tuple[str, ...] + mapped_and_excluded_python_tests: tuple[str, ...] + invalid_unit_parity_exclusions: tuple[str, ...] + + @property + def mapped_count(self) -> int: + return len(self.mapped_python_tests) + + @property + def total_count(self) -> int: + return len(self.python_tests) + + @property + def percentage(self) -> float: + return 0.0 if not self.total_count else round(100.0 * self.mapped_count / self.total_count, 1) + + @property + def is_valid(self) -> bool: + return not ( + self.missing_python_tests + or self.missing_rust_tests + or self.duplicate_python_mappings + or self.duplicate_rust_mappings + or self.invalid_mapping_exclusions + or self.mapped_and_excluded_python_tests + or self.invalid_unit_parity_exclusions + ) + + +def audit_mapping( + contract: UnitTestContract, + repo_root: Path, + *, + python_inventory: PythonInventory = collect_python_tests, + rust_inventory: RustInventory = enumerate_rust_tests, +) -> MappingReport: + mapping: Final = contract.mapping + traced_python: tuple[PythonFunctionIdentity, ...] = () + traced_rust: tuple[RustFunctionIdentity, ...] = () + if mapping.python_functions is not None and mapping.python_functions.trace_module is not None: + traced_python, traced_rust = _trace_functions(mapping.python_functions) + python_tests: Final = ( + collect_python_function_inventory(mapping.python_functions, repo_root, traced_python) + if mapping.python_functions is not None + else python_inventory(mapping.python_selectors, repo_root) + ) + unit_parity_tests: Final = python_inventory(contract.unit_parity.python_selectors, repo_root) + traced_scope: Final = _traced_rust_scope(traced_rust, mapping.rust_targets, repo_root) if traced_rust else () + rust_scope: Final = _merge_rust_scopes( + (*mapping.rust_scope, *traced_scope, *_colocated_rust_scope(mapping.mappings)) + ) + rust_tests: Final = rust_inventory(repo_root, rust_scope) + mapped_python: Final = frozenset(item.python for item in mapping.mappings) + excluded_python: Final = frozenset(exclusion.nodeid for exclusion in mapping.exclusions) + rust_ownership: Final = tuple((item.rust, _owned_rust_tests(item.rust, rust_tests)) for item in mapping.mappings) + mapped_rust: Final = frozenset(identity for _, identities in rust_ownership for identity in identities) + duplicate_python: Final = tuple( + sorted(nodeid for nodeid, count in Counter(item.python for item in mapping.mappings).items() if count > 1) + ) + duplicate_exact_rust: Final = frozenset( + identity.key + for identity, count in Counter( + item.rust for item in mapping.mappings if isinstance(item.rust, RustTestIdentity) + ).items() + if count > 1 + ) + duplicate_owned_rust: Final = frozenset( + identity.key + for identity, count in Counter(identity for _, identities in rust_ownership for identity in identities).items() + if count > 1 + ) + duplicate_rust: Final = tuple(sorted(duplicate_exact_rust | duplicate_owned_rust)) + return MappingReport( + python_tests=tuple(sorted(python_tests)), + rust_tests=tuple(sorted(identity.key for identity in rust_tests)), + mapped_python_tests=tuple(sorted(python_tests & mapped_python)), + excluded_python_tests=tuple(sorted((python_tests & excluded_python) - mapped_python)), + unmapped_python_tests=tuple(sorted(python_tests - mapped_python - excluded_python)), + rust_only_tests=tuple(sorted(identity.key for identity in rust_tests - mapped_rust)), + missing_python_tests=tuple(sorted(mapped_python - python_tests)), + missing_rust_tests=tuple(sorted(rust.key for rust, identities in rust_ownership if not identities)), + duplicate_python_mappings=duplicate_python, + duplicate_rust_mappings=duplicate_rust, + invalid_mapping_exclusions=tuple(sorted(excluded_python - python_tests)), + mapped_and_excluded_python_tests=tuple(sorted(mapped_python & excluded_python)), + invalid_unit_parity_exclusions=tuple( + sorted( + exclusion.nodeid + for exclusion in contract.unit_parity.exclusions + if exclusion.nodeid not in unit_parity_tests + ) + ), + ) diff --git a/tests/rust-python-harness/strategies/unit_tests_mapping/mappings.py b/tests/rust-python-harness/strategies/unit_tests_mapping/mappings.py new file mode 100644 index 00000000000..efb5b2a644a --- /dev/null +++ b/tests/rust-python-harness/strategies/unit_tests_mapping/mappings.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +from collections.abc import Mapping +from types import MappingProxyType +from typing import Final + +from ...shared.reporting.models import SdkFunction +from .cases.ocr import OCR_CONTRACT +from .contracts import UnitTestContract + +UNIT_TEST_CONTRACTS: Final[Mapping[SdkFunction, UnitTestContract]] = MappingProxyType({"ocr": OCR_CONTRACT}) diff --git a/tests/rust-python-harness/strategies/unit_tests_mapping/reporting.py b/tests/rust-python-harness/strategies/unit_tests_mapping/reporting.py new file mode 100644 index 00000000000..d4bce7bc768 --- /dev/null +++ b/tests/rust-python-harness/strategies/unit_tests_mapping/reporting.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from collections.abc import Sequence +from typing import Final + +from pydantic import ValidationError + +from ...shared.reporting.models import CaseResult +from ...shared.reporting.rendering import ReportSection, render_case_outcome +from .mapping_report import MappingReportArtifact, mapping_report_lines +from .runner import MAPPING_REPORT_ARTIFACT + + +def _render_artifact(body: str) -> str: + try: + artifact: Final = MappingReportArtifact.model_validate_json(body) + except ValidationError as error: + return f"Mapping report artifact is invalid: {error}" + return "\n".join(mapping_report_lines(artifact.report, detailed=artifact.detailed)) + + +def _render_result(result: CaseResult) -> str: + reports: Final = tuple( + _render_artifact(artifact.body) + for artifacts in result.artifacts.values() + for artifact in artifacts + if artifact.kind == MAPPING_REPORT_ARTIFACT + ) + if reports: + return "\n".join((f"Case: {result.case.display_name}", *reports)) + return render_case_outcome(result) + + +def render_mapping_results(results: Sequence[CaseResult]) -> tuple[ReportSection, ...]: + blocks: Final = tuple(_render_result(result) for result in results) + return (ReportSection("Python/Rust unit-test mappings", blocks or ("No mapping cases selected",)),) diff --git a/tests/rust-python-harness/strategies/unit_tests_mapping/runner.py b/tests/rust-python-harness/strategies/unit_tests_mapping/runner.py new file mode 100644 index 00000000000..540edca9385 --- /dev/null +++ b/tests/rust-python-harness/strategies/unit_tests_mapping/runner.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from collections.abc import Sequence +from pathlib import Path +from typing import Final + +from ...shared.native_build import ensure_trace_bridge +from ...shared.reporting.models import ResultArtifact +from ...shared.unit_runners.python_runner import collect_python_tests +from ...shared.unit_runners.rust_runner import enumerate_rust_tests +from ...shared.unit_runners.suite_runner import SuiteExecution +from .contracts import UnitTestContract +from .mapping_report import MappingReportArtifact +from .mapping_validator import PythonInventory, RustInventory, audit_mapping + +MAPPING_REPORT_ARTIFACT: Final = "mapping_report" + + +def _audit_problems(artifact: MappingReportArtifact) -> tuple[str, ...]: + report: Final = artifact.report + return ( + *(f"mapped Python test does not exist: {nodeid}" for nodeid in report.missing_python_tests), + *(f"mapped Rust test does not exist: {nodeid}" for nodeid in report.missing_rust_tests), + *(f"Python test has multiple mappings: {nodeid}" for nodeid in report.duplicate_python_mappings), + *(f"Rust test has multiple mappings: {nodeid}" for nodeid in report.duplicate_rust_mappings), + *(f"mapping exclusion does not exist: {nodeid}" for nodeid in report.invalid_mapping_exclusions), + *(f"Python test is both mapped and excluded: {nodeid}" for nodeid in report.mapped_and_excluded_python_tests), + *(f"unit parity exclusion does not exist: {nodeid}" for nodeid in report.invalid_unit_parity_exclusions), + ) + + +def run_suite( + contract: UnitTestContract, + repo_root: Path, + runner_args: Sequence[str] = (), + *, + python_inventory: PythonInventory = collect_python_tests, + rust_inventory: RustInventory = enumerate_rust_tests, +) -> SuiteExecution: + if contract.mapping.python_functions is not None and contract.mapping.python_functions.trace_module is not None: + bridge_error: Final = ensure_trace_bridge(repo_root) + if bridge_error is not None: + return SuiteExecution(problems=(bridge_error,)) + artifact: Final = MappingReportArtifact( + report=audit_mapping( + contract, + repo_root, + python_inventory=python_inventory, + rust_inventory=rust_inventory, + ), + detailed=bool(runner_args), + ) + completeness_problems: Final = ( + tuple(f"Python test has no Rust mapping: {nodeid}" for nodeid in artifact.report.unmapped_python_tests) + if contract.mapping.require_complete + else () + ) + return SuiteExecution( + problems=(*_audit_problems(artifact), *completeness_problems), + artifacts=(ResultArtifact(MAPPING_REPORT_ARTIFACT, artifact.model_dump_json()),), + ) diff --git a/tests/rust-python-harness/strategies/unit_tests_mapping/test_mapping_validator.py b/tests/rust-python-harness/strategies/unit_tests_mapping/test_mapping_validator.py new file mode 100644 index 00000000000..6635a0eb522 --- /dev/null +++ b/tests/rust-python-harness/strategies/unit_tests_mapping/test_mapping_validator.py @@ -0,0 +1,314 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Final + +import pytest +from pydantic import ValidationError + +from ...shared.unit_runners.rust_runner import RustTarget, RustTestIdentity, RustTestScope +from .contracts import ( + MappingExclusionSpec, + MappingSpec, + RustTestFamily, + RustUnitSpec, + UnitParityExclusionSpec, + UnitParitySpec, + UnitTestContract, +) +from .contracts import TestMapping as MappingPair +from .mapping_validator import audit_mapping + +_TARGET: Final = RustTarget(package="example", name="example", kind="lib") +_SCOPE: Final = RustTestScope(target=_TARGET, modules=("api::tests",)) +_PYTHON_TESTS: Final = frozenset(("test_api.py::test_decode", "test_api.py::test_unmapped")) +_RUST_TEST: Final = RustTestIdentity(target=_TARGET, name="api::tests::decodes") +_RUST_ONLY: Final = RustTestIdentity(target=_TARGET, name="api::tests::rust_only") +_RUST_TESTS: Final = frozenset((_RUST_TEST, _RUST_ONLY)) + + +def _python_inventory(*_: object) -> frozenset[str]: + return _PYTHON_TESTS + + +def _rust_inventory(*_: object) -> frozenset[RustTestIdentity]: + return _RUST_TESTS + + +def _contract(*mappings: MappingPair, exclusions: tuple[UnitParityExclusionSpec, ...] = ()) -> UnitTestContract: + return UnitTestContract( + mapping=MappingSpec( + python_selectors=("test_api.py",), + rust_scope=(_SCOPE,), + mappings=mappings, + ), + unit_parity=UnitParitySpec(python_selectors=("test_api.py",), exclusions=exclusions), + rust=RustUnitSpec(cargo_manifest="Cargo.toml", cargo_filter="api"), + ) + + +def _mapping_exclusion(nodeid: str) -> MappingExclusionSpec: + return MappingExclusionSpec(nodeid=nodeid, reason="Python bridge availability is host-only") + + +def test_derives_mapping_status_from_live_inventories(tmp_path: Path) -> None: + contract: Final = _contract(MappingPair(python="test_api.py::test_decode", rust=_RUST_TEST)) + + report: Final = audit_mapping( + contract, tmp_path, python_inventory=_python_inventory, rust_inventory=_rust_inventory + ) + + assert report.is_valid + assert report.mapped_python_tests == ("test_api.py::test_decode",) + assert report.unmapped_python_tests == ("test_api.py::test_unmapped",) + assert report.rust_only_tests == (_RUST_ONLY.key,) + assert report.percentage == 50.0 + + +def test_reports_stale_and_duplicate_mappings(tmp_path: Path) -> None: + removed: Final = RustTestIdentity(target=_TARGET, name="api::tests::removed") + contract: Final = _contract( + MappingPair(python="test_api.py::removed", rust=removed), + MappingPair(python="test_api.py::removed", rust=_RUST_TEST), + ) + + report: Final = audit_mapping( + contract, tmp_path, python_inventory=_python_inventory, rust_inventory=_rust_inventory + ) + + assert not report.is_valid + assert report.missing_python_tests == ("test_api.py::removed",) + assert report.missing_rust_tests == (removed.key,) + assert report.duplicate_python_mappings == ("test_api.py::removed",) + + +def test_reports_duplicate_rust_mapping_and_invalid_exclusion(tmp_path: Path) -> None: + contract: Final = _contract( + MappingPair(python="test_api.py::test_decode", rust=_RUST_TEST), + MappingPair(python="test_api.py::test_unmapped", rust=_RUST_TEST), + exclusions=(UnitParityExclusionSpec(nodeid="test_api.py::removed", reason="Removed test"),), + ) + + report: Final = audit_mapping( + contract, tmp_path, python_inventory=_python_inventory, rust_inventory=_rust_inventory + ) + + assert not report.is_valid + assert report.duplicate_rust_mappings == (_RUST_TEST.key,) + assert report.invalid_unit_parity_exclusions == ("test_api.py::removed",) + + +def test_excludes_host_only_python_test_from_unmapped_inventory(tmp_path: Path) -> None: + partial: Final = _contract(MappingPair(python="test_api.py::test_decode", rust=_RUST_TEST)) + contract: Final = partial.model_copy( + update={ + "mapping": partial.mapping.model_copy( + update={"exclusions": (_mapping_exclusion("test_api.py::test_unmapped"),)} + ) + } + ) + + report: Final = audit_mapping( + contract, tmp_path, python_inventory=_python_inventory, rust_inventory=_rust_inventory + ) + + assert report.is_valid + assert report.excluded_python_tests == ("test_api.py::test_unmapped",) + assert report.unmapped_python_tests == () + + +def test_reports_missing_and_mapped_mapping_exclusions(tmp_path: Path) -> None: + partial: Final = _contract(MappingPair(python="test_api.py::test_decode", rust=_RUST_TEST)) + contract: Final = partial.model_copy( + update={ + "mapping": partial.mapping.model_copy( + update={ + "exclusions": ( + _mapping_exclusion("test_api.py::test_decode"), + _mapping_exclusion("test_api.py::removed"), + ) + } + ) + } + ) + + report: Final = audit_mapping( + contract, tmp_path, python_inventory=_python_inventory, rust_inventory=_rust_inventory + ) + + assert not report.is_valid + assert report.invalid_mapping_exclusions == ("test_api.py::removed",) + assert report.mapped_and_excluded_python_tests == ("test_api.py::test_decode",) + + +def test_resolves_rstest_family_to_generated_cases(tmp_path: Path) -> None: + first_case: Final = RustTestIdentity(target=_TARGET, name="api::tests::decodes::case_1_png") + second_case: Final = RustTestIdentity(target=_TARGET, name="api::tests::decodes::case_2_pdf") + family: Final = RustTestFamily(target=_TARGET, name="api::tests::decodes") + contract: Final = _contract(MappingPair(python="test_api.py::test_decode", rust=family)) + + report: Final = audit_mapping( + contract, + tmp_path, + python_inventory=_python_inventory, + rust_inventory=lambda *_: frozenset((first_case, second_case)), + ) + + assert report.is_valid + assert report.mapped_python_tests == ("test_api.py::test_decode",) + assert report.missing_rust_tests == () + + +def test_reports_missing_rstest_family(tmp_path: Path) -> None: + family: Final = RustTestFamily(target=_TARGET, name="api::tests::decodes") + contract: Final = _contract(MappingPair(python="test_api.py::test_decode", rust=family)) + + report: Final = audit_mapping( + contract, tmp_path, python_inventory=_python_inventory, rust_inventory=_rust_inventory + ) + + assert not report.is_valid + assert report.missing_rust_tests == (family.key,) + + +def test_reports_concrete_test_owned_by_exact_and_family_mappings(tmp_path: Path) -> None: + generated: Final = RustTestIdentity(target=_TARGET, name="api::tests::decodes::case_1_png") + family: Final = RustTestFamily(target=_TARGET, name="api::tests::decodes") + contract: Final = _contract( + MappingPair(python="test_api.py::test_decode", rust=family), + MappingPair(python="test_api.py::test_unmapped", rust=generated), + ) + + report: Final = audit_mapping( + contract, + tmp_path, + python_inventory=_python_inventory, + rust_inventory=lambda *_: frozenset((generated,)), + ) + + assert not report.is_valid + assert report.duplicate_rust_mappings == (generated.key,) + + +def test_rstest_family_cases_are_not_rust_only(tmp_path: Path) -> None: + generated: Final = RustTestIdentity(target=_TARGET, name="api::tests::decodes::case_1_png") + unrelated: Final = RustTestIdentity(target=_TARGET, name="api::tests::rust_only") + family: Final = RustTestFamily(target=_TARGET, name="api::tests::decodes") + contract: Final = _contract(MappingPair(python="test_api.py::test_decode", rust=family)) + + report: Final = audit_mapping( + contract, + tmp_path, + python_inventory=_python_inventory, + rust_inventory=lambda *_: frozenset((generated, unrelated)), + ) + + assert report.rust_only_tests == (unrelated.key,) + + +def test_merges_configured_and_colocated_rust_scopes(tmp_path: Path) -> None: + support_test: Final = RustTestIdentity(target=_TARGET, name="support::tests::rust_only") + configured_scope: Final = RustTestScope( + target=_TARGET, + modules=("support::tests",), + features=("mock",), + default_features=False, + ) + expected_scope: Final = RustTestScope( + target=_TARGET, + modules=("api::tests", "support::tests"), + features=("mock",), + default_features=False, + ) + contract: Final = UnitTestContract( + mapping=MappingSpec( + python_selectors=("test_api.py",), + rust_scope=(configured_scope,), + mappings=(MappingPair(python="test_api.py::test_decode", rust=_RUST_TEST),), + ), + unit_parity=UnitParitySpec(python_selectors=("test_api.py",)), + rust=RustUnitSpec(cargo_manifest="Cargo.toml", cargo_filter="api"), + ) + + def assert_merged_scope(_: Path, scopes: tuple[RustTestScope, ...]) -> frozenset[RustTestIdentity]: + assert scopes == (expected_scope,) + return frozenset((_RUST_TEST, support_test)) + + report: Final = audit_mapping( + contract, + tmp_path, + python_inventory=_python_inventory, + rust_inventory=assert_merged_scope, + ) + + assert report.is_valid + assert report.rust_only_tests == (support_test.key,) + + +def test_merged_rust_scope_removes_modules_contained_by_parent(tmp_path: Path) -> None: + expected_scope: Final = RustTestScope(target=_TARGET, modules=("api",)) + contract: Final = UnitTestContract( + mapping=MappingSpec( + python_selectors=("test_api.py",), + rust_scope=(expected_scope,), + mappings=(MappingPair(python="test_api.py::test_decode", rust=_RUST_TEST),), + ), + unit_parity=UnitParitySpec(python_selectors=("test_api.py",)), + rust=RustUnitSpec(cargo_manifest="Cargo.toml", cargo_filter="api"), + ) + + def assert_parent_scope(_: Path, scopes: tuple[RustTestScope, ...]) -> frozenset[RustTestIdentity]: + assert scopes == (expected_scope,) + return frozenset((_RUST_TEST,)) + + report: Final = audit_mapping( + contract, + tmp_path, + python_inventory=_python_inventory, + rust_inventory=assert_parent_scope, + ) + + assert report.is_valid + + +def test_accepts_descendant_unit_parity_selector() -> None: + contract: Final = UnitTestContract( + mapping=MappingSpec(python_selectors=("tests/api",), rust_scope=(_SCOPE,), mappings=()), + unit_parity=UnitParitySpec(python_selectors=("tests/api/test_ocr.py",)), + rust=RustUnitSpec(cargo_manifest="Cargo.toml", cargo_filter="api"), + ) + + assert contract.unit_parity.python_selectors == ("tests/api/test_ocr.py",) + + +@pytest.mark.parametrize( + "mapping_selectors,parity_selectors", + (((), ("tests/api",)), (("tests/api", "tests/api"), ("tests/api",)), (("tests/api",), ("tests/chat",))), +) +def test_rejects_invalid_selector_contracts( + mapping_selectors: tuple[str, ...], parity_selectors: tuple[str, ...] +) -> None: + with pytest.raises(ValidationError): + UnitTestContract( + mapping=MappingSpec(python_selectors=mapping_selectors, rust_scope=(_SCOPE,), mappings=()), + unit_parity=UnitParitySpec(python_selectors=parity_selectors), + rust=RustUnitSpec(cargo_manifest="Cargo.toml", cargo_filter="api"), + ) + + +def test_rejects_duplicate_scopes_and_exclusions() -> None: + exclusion: Final = UnitParityExclusionSpec(nodeid="test_api.py::test_skip", reason="Backend assertion") + with pytest.raises(ValidationError, match="duplicate targets"): + MappingSpec(python_selectors=("test_api.py",), rust_scope=(_SCOPE, _SCOPE), mappings=()) + with pytest.raises(ValidationError, match="duplicate nodeids"): + UnitParitySpec(python_selectors=("test_api.py",), exclusions=(exclusion, exclusion)) + mapping_exclusion: Final = _mapping_exclusion("test_api.py::test_skip") + with pytest.raises(ValidationError, match="mapping exclusions contain duplicate nodeids"): + MappingSpec( + python_selectors=("test_api.py",), + rust_scope=(_SCOPE,), + mappings=(), + exclusions=(mapping_exclusion, mapping_exclusion), + ) + with pytest.raises(ValidationError, match="must be a non-empty string"): + MappingExclusionSpec(nodeid="test_api.py::test_skip", reason=" ") diff --git a/tests/rust-python-harness/strategies/unit_tests_mapping/test_reporting.py b/tests/rust-python-harness/strategies/unit_tests_mapping/test_reporting.py new file mode 100644 index 00000000000..36e18a9d109 --- /dev/null +++ b/tests/rust-python-harness/strategies/unit_tests_mapping/test_reporting.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +from typing import Final + +from ...shared.reporting.models import CaseResult, Coverage, HarnessCase, ResultArtifact, RunStatus +from ...shared.reporting.strategy import SuiteCaseSpec +from .mapping_report import MappingReportArtifact +from .mapping_validator import MappingReport +from .reporting import render_mapping_results +from .runner import MAPPING_REPORT_ARTIFACT + + +def _report(*, invalid: bool = False, excluded: bool = False) -> MappingReport: + return MappingReport( + python_tests=("test_api.py::test_decode", "test_api.py::test_unmapped"), + rust_tests=("example/lib/example::api::tests::decodes", "example/lib/example::api::tests::rust_only"), + mapped_python_tests=("test_api.py::test_decode",), + excluded_python_tests=(("test_api.py::test_unmapped",) if excluded else ()), + unmapped_python_tests=(() if excluded else ("test_api.py::test_unmapped",)), + rust_only_tests=("example/lib/example::api::tests::rust_only",), + missing_python_tests=("test_api.py::removed",) if invalid else (), + missing_rust_tests=(), + duplicate_python_mappings=(), + duplicate_rust_mappings=(), + invalid_mapping_exclusions=(), + mapped_and_excluded_python_tests=(), + invalid_unit_parity_exclusions=(), + ) + + +def _result(body: str) -> CaseResult: + case: Final = HarnessCase( + strategy_id="unit_tests_mapping", + strategy_label="Unit test mapping", + sdk_function="ocr", + spec=SuiteCaseSpec(coverage=Coverage.COMPLETE, suite="ocr"), + ) + result: Final = CaseResult(case=case) + result.record( + "suite:unit_tests_mapping:ocr:ocr", + RunStatus.PASSED, + artifacts=(ResultArtifact(MAPPING_REPORT_ARTIFACT, body),), + ) + return result + + +def test_renderer_preserves_summary_and_detailed_output() -> None: + summary: Final = MappingReportArtifact(report=_report()).model_dump_json() + detailed: Final = MappingReportArtifact(report=_report(), detailed=True).model_dump_json() + + summary_text: Final = "\n".join(render_mapping_results((_result(summary),))[0].blocks) + detailed_text: Final = "\n".join(render_mapping_results((_result(detailed),))[0].blocks) + + assert "Mapped 1 / 2 (50.0%)" in summary_text + assert "Unmapped Python test details" not in summary_text + assert "Unmapped Python test details\n test_api.py\n test_unmapped" in detailed_text + assert "Rust-only test details" in detailed_text + + +def test_renderer_shows_contract_errors() -> None: + body: Final = MappingReportArtifact(report=_report(invalid=True)).model_dump_json() + rendered: Final = "\n".join(render_mapping_results((_result(body),))[0].blocks) + + assert "Contract: FAIL" in rendered + assert "Missing Python test: test_api.py::removed" in rendered + + +def test_renderer_distinguishes_excluded_python_tests() -> None: + body: Final = MappingReportArtifact(report=_report(excluded=True), detailed=True).model_dump_json() + rendered: Final = "\n".join(render_mapping_results((_result(body),))[0].blocks) + + assert "Excluded 1 / 2 (50.0%)" in rendered + assert "Unmapped 0 / 2 (0.0%)" in rendered + assert "Excluded Python test details\n test_api.py\n test_unmapped" in rendered + + +def test_renderer_handles_empty_inventory_and_malformed_artifact() -> None: + empty: Final = MappingReport( + python_tests=(), + rust_tests=(), + mapped_python_tests=(), + excluded_python_tests=(), + unmapped_python_tests=(), + rust_only_tests=(), + missing_python_tests=(), + missing_rust_tests=(), + duplicate_python_mappings=(), + duplicate_rust_mappings=(), + invalid_mapping_exclusions=(), + mapped_and_excluded_python_tests=(), + invalid_unit_parity_exclusions=(), + ) + empty_text: Final = "\n".join( + render_mapping_results((_result(MappingReportArtifact(report=empty).model_dump_json()),))[0].blocks + ) + invalid_text: Final = "\n".join(render_mapping_results((_result("not-json"),))[0].blocks) + + assert "Mapped 0 / 0 (0.0%)" in empty_text + assert "Mapping report artifact is invalid:" in invalid_text diff --git a/tests/rust-python-harness/strategies/unit_tests_mapping/test_runner.py b/tests/rust-python-harness/strategies/unit_tests_mapping/test_runner.py new file mode 100644 index 00000000000..2b14c716e1d --- /dev/null +++ b/tests/rust-python-harness/strategies/unit_tests_mapping/test_runner.py @@ -0,0 +1,166 @@ +from __future__ import annotations + +from functools import partial +from pathlib import Path +from typing import Final + +from ...shared.reporting.models import Coverage, HarnessCase, RunStatus +from ...shared.reporting.strategy import SuiteCaseSpec +from ...shared.unit_runners.rust_runner import RustTarget, RustTestIdentity, RustTestScope +from ...shared.unit_runners.suite_runner import run_suites +from .contracts import ( + MappingExclusionSpec, + MappingSpec, + RustUnitSpec, + TestMapping as MappingPair, + UnitParitySpec, + UnitTestContract, +) +from .mapping_report import MappingReportArtifact +from .runner import MAPPING_REPORT_ARTIFACT, run_suite + +_TARGET: Final = RustTarget(package="example", name="example", kind="lib") +_RUST_TEST: Final = RustTestIdentity(target=_TARGET, name="api::tests::decodes") +_RUST_ONLY: Final = RustTestIdentity(target=_TARGET, name="api::tests::rust_only") + + +def _python_inventory(*_: object) -> frozenset[str]: + return frozenset(("test_api.py::test_decode", "test_api.py::test_unmapped")) + + +def _rust_inventory(*_: object) -> frozenset[RustTestIdentity]: + return frozenset((_RUST_TEST, _RUST_ONLY)) + + +def _contract(mapping: MappingPair) -> UnitTestContract: + return UnitTestContract( + mapping=MappingSpec( + python_selectors=("test_api.py",), + rust_scope=(RustTestScope(target=_TARGET, modules=("api::tests",)),), + mappings=(mapping,), + ), + unit_parity=UnitParitySpec(python_selectors=("test_api.py",)), + rust=RustUnitSpec(cargo_manifest="Cargo.toml", cargo_filter="api"), + ) + + +def _case() -> HarnessCase: + return HarnessCase( + strategy_id="unit_tests_mapping", + strategy_label="Unit test mapping", + sdk_function="ocr", + spec=SuiteCaseSpec(coverage=Coverage.COMPLETE, suite="ocr"), + ) + + +def test_reports_structured_mapping_status_without_running_tests(tmp_path: Path) -> None: + contract: Final = _contract(MappingPair(python="test_api.py::test_decode", rust=_RUST_TEST)) + case: Final = _case() + + code, report = run_suites( + (case,), + tmp_path, + lambda _: None, + suites={"ocr": contract}, + execute=partial( + run_suite, + python_inventory=_python_inventory, + rust_inventory=_rust_inventory, + ), + ) + + result: Final = report.results[case.key] + artifacts: Final = tuple( + artifact + for values in result.artifacts.values() + for artifact in values + if artifact.kind == MAPPING_REPORT_ARTIFACT + ) + parsed: Final = MappingReportArtifact.model_validate_json(artifacts[0].body) + assert code == 0, report.failures + assert result.status is RunStatus.PASSED + assert parsed.report.mapped_count == 1 + assert parsed.report.total_count == 2 + assert not parsed.detailed + + +def test_fails_when_a_mapping_target_is_missing(tmp_path: Path) -> None: + missing: Final = RustTestIdentity(target=_TARGET, name="api::tests::missing") + contract: Final = _contract(MappingPair(python="test_api.py::test_decode", rust=missing)) + case: Final = _case() + + code, report = run_suites( + (case,), + tmp_path, + lambda _: None, + suites={"ocr": contract}, + execute=partial( + run_suite, + python_inventory=_python_inventory, + rust_inventory=_rust_inventory, + ), + ) + + assert code == 1 + assert report.results[case.key].status is RunStatus.FAILED + assert any("mapped Rust test does not exist" in detail for _, detail in report.failures) + + +def test_required_complete_mapping_fails_for_unmapped_python_test(tmp_path: Path) -> None: + partial: Final = _contract(MappingPair(python="test_api.py::test_decode", rust=_RUST_TEST)) + contract: Final = partial.model_copy( + update={"mapping": partial.mapping.model_copy(update={"require_complete": True})} + ) + + execution: Final = run_suite( + contract, + tmp_path, + python_inventory=_python_inventory, + rust_inventory=_rust_inventory, + ) + + assert execution.problems == ("Python test has no Rust mapping: test_api.py::test_unmapped",) + + +def test_required_complete_mapping_accepts_host_only_exclusion(tmp_path: Path) -> None: + partial: Final = _contract(MappingPair(python="test_api.py::test_decode", rust=_RUST_TEST)) + contract: Final = partial.model_copy( + update={ + "mapping": partial.mapping.model_copy( + update={ + "require_complete": True, + "exclusions": ( + MappingExclusionSpec( + nodeid="test_api.py::test_unmapped", + reason="Python bridge availability is host-only", + ), + ), + } + ) + } + ) + + execution: Final = run_suite( + contract, + tmp_path, + python_inventory=_python_inventory, + rust_inventory=_rust_inventory, + ) + artifact: Final = MappingReportArtifact.model_validate_json(execution.artifacts[0].body) + + assert execution.problems == () + assert artifact.report.excluded_python_tests == ("test_api.py::test_unmapped",) + + +def test_detail_argument_is_stored_in_artifact(tmp_path: Path) -> None: + contract: Final = _contract(MappingPair(python="test_api.py::test_decode", rust=_RUST_TEST)) + execution: Final = run_suite( + contract, + tmp_path, + ("full",), + python_inventory=_python_inventory, + rust_inventory=_rust_inventory, + ) + artifact: Final = MappingReportArtifact.model_validate_json(execution.artifacts[0].body) + + assert artifact.detailed diff --git a/tests/rust-python-harness/strategies/unit_tests_parity/AGENTS.md b/tests/rust-python-harness/strategies/unit_tests_parity/AGENTS.md new file mode 100644 index 00000000000..ccab6b1ff12 --- /dev/null +++ b/tests/rust-python-harness/strategies/unit_tests_parity/AGENTS.md @@ -0,0 +1 @@ +Runs the existing litellm Python unit tests with LITELLM_RUST=0 and LITELLM_RUST=1 in separate processes and requires the two runs to match, including on failures. diff --git a/tests/rust-python-harness/strategies/unit_tests_parity/__init__.py b/tests/rust-python-harness/strategies/unit_tests_parity/__init__.py new file mode 100644 index 00000000000..0067bf6dfe5 --- /dev/null +++ b/tests/rust-python-harness/strategies/unit_tests_parity/__init__.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +from collections.abc import Mapping +from functools import partial +from pathlib import Path +from types import MappingProxyType +from typing import Final + +from ...shared.reporting.models import SDK_FUNCTIONS, Coverage, SdkFunction +from ...shared.reporting.strategy import ( + CaseDefinition, + NotImplementedCaseSpec, + RunnerArgumentDefinition, + StrategyDefinition, + SuiteCaseSpec, +) +from ...shared.unit_runners.suite_runner import run_suites +from ..unit_tests_mapping.mappings import UNIT_TEST_CONTRACTS +from .reporting import render_unit_parity_results +from .runner import UnitParityExclusion, UnitParitySuite, run_suite + + +UNIT_PARITY_SUITES: Final[Mapping[SdkFunction, UnitParitySuite]] = MappingProxyType( + { + sdk_function: UnitParitySuite( + python_selectors=contract.unit_parity.python_selectors, + exclusions=tuple( + UnitParityExclusion( + nodeid=exclusion.nodeid, + reason=exclusion.reason, + ) + for exclusion in contract.unit_parity.exclusions + ), + ) + for sdk_function, contract in UNIT_TEST_CONTRACTS.items() + } +) + + +CASES: Final[tuple[CaseDefinition, ...]] = ( + *( + CaseDefinition( + sdk_function, + SuiteCaseSpec(coverage=Coverage.COMPLETE, suite=sdk_function) + if sdk_function in UNIT_PARITY_SUITES + else NotImplementedCaseSpec(reason=f"No {sdk_function} unit-test parity suite is registered."), + ) + for sdk_function in SDK_FUNCTIONS + ), +) + +STRATEGY: Final = StrategyDefinition( + id="unit_tests_parity", + order=31, + label="Unit test parity", + description=( + "Run existing Python unit tests with LITELLM_RUST disabled and enabled and require matching outcomes." + ), + directory=Path(__file__).parent, + runnable_spec=SuiteCaseSpec, + cases=CASES, + run=partial(run_suites, suites=UNIT_PARITY_SUITES, execute=run_suite), + render=render_unit_parity_results, + runner_argument=RunnerArgumentDefinition( + option="--pytest-arg", + help="append an argument to both Python and Rust-backed pytest runs", + ), +) diff --git a/tests/rust-python-harness/strategies/unit_tests_parity/reporting.py b/tests/rust-python-harness/strategies/unit_tests_parity/reporting.py new file mode 100644 index 00000000000..339e893c60d --- /dev/null +++ b/tests/rust-python-harness/strategies/unit_tests_parity/reporting.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +from collections.abc import Sequence +from typing import Final + +from ...shared.reporting.models import CaseResult +from ...shared.reporting.rendering import ReportSection, render_case_outcome + + +def render_unit_parity_results(results: Sequence[CaseResult]) -> tuple[ReportSection, ...]: + blocks: Final = tuple(render_case_outcome(result) for result in results) + return (ReportSection("Python backend parity outcomes", blocks or ("No unit-parity cases selected",)),) diff --git a/tests/rust-python-harness/strategies/unit_tests_parity/runner.py b/tests/rust-python-harness/strategies/unit_tests_parity/runner.py new file mode 100644 index 00000000000..5a3a70ea03c --- /dev/null +++ b/tests/rust-python-harness/strategies/unit_tests_parity/runner.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from collections.abc import Sequence +from pathlib import Path +from typing import Final + +from pydantic import BaseModel, ConfigDict + +from ...shared.unit_runners.python_runner import BackendSpec, compare_python_runs, run_python_tests +from ...shared.unit_runners.suite_runner import SuiteExecution + +BACKEND: Final = BackendSpec(environment_variable="LITELLM_RUST") + + +class UnitParityExclusion(BaseModel): + model_config = ConfigDict(frozen=True, extra="forbid") + + nodeid: str + reason: str + + +class UnitParitySuite(BaseModel): + model_config = ConfigDict(frozen=True, extra="forbid") + + python_selectors: tuple[str, ...] + exclusions: tuple[UnitParityExclusion, ...] = () + + +def run_suite(suite: UnitParitySuite, repo_root: Path, pytest_args: Sequence[str] = ()) -> SuiteExecution: + if not suite.python_selectors: + return SuiteExecution(problems=("unit parity suites must select Python tests",)) + deselections: Final = tuple(f"--deselect={exclusion.nodeid}" for exclusion in suite.exclusions) + args: Final = (*pytest_args, *deselections) + python: Final = run_python_tests(suite.python_selectors, repo_root, "python", BACKEND, args) + rust: Final = run_python_tests(suite.python_selectors, repo_root, "rust", BACKEND, args) + return SuiteExecution(problems=compare_python_runs(python, rust)) diff --git a/tests/rust-python-harness/strategies/unit_tests_parity/test_runner.py b/tests/rust-python-harness/strategies/unit_tests_parity/test_runner.py new file mode 100644 index 00000000000..a4a9524c85f --- /dev/null +++ b/tests/rust-python-harness/strategies/unit_tests_parity/test_runner.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Final + +from ...shared.reporting.models import Coverage, HarnessCase, HarnessRun, RunStatus +from ...shared.reporting.strategy import SuiteCaseSpec +from ...shared.unit_runners.suite_runner import run_suites +from .runner import UnitParityExclusion, UnitParitySuite, run_suite + + +def _write_tests(tmp_path: Path, *, mismatch: bool = False, failing: bool = False) -> None: + (tmp_path / "pytest.ini").write_text("[pytest]\n") + (tmp_path / "test_api.py").write_text( + "import os\n" + "def test_decode():\n assert int('42') == 42\n" + + ("def test_backend():\n assert os.environ['LITELLM_RUST'] == '0'\n" if mismatch else "") + + ("def test_fails():\n assert False\n" if failing else "") + ) + + +def _case() -> HarnessCase: + return HarnessCase( + strategy_id="unit_tests_parity", + strategy_label="Unit test parity", + sdk_function="ocr", + spec=SuiteCaseSpec(coverage=Coverage.COMPLETE, suite="ocr"), + ) + + +def _run(case: HarnessCase, tmp_path: Path, suite: UnitParitySuite) -> tuple[int, HarnessRun]: + return run_suites((case,), tmp_path, lambda _: None, suites={"ocr": suite}, execute=run_suite) + + +def test_passes_when_both_backends_agree(tmp_path: Path) -> None: + _write_tests(tmp_path) + case: Final = _case() + + code, report = _run(case, tmp_path, UnitParitySuite(python_selectors=("test_api.py",))) + + assert code == 0, report.failures + assert report.results[case.key].status is RunStatus.PASSED + + +def test_passes_when_both_backends_fail_identically(tmp_path: Path) -> None: + _write_tests(tmp_path, failing=True) + case: Final = _case() + + code, report = _run(case, tmp_path, UnitParitySuite(python_selectors=("test_api.py",))) + + assert code == 0, report.failures + assert report.results[case.key].status is RunStatus.PASSED + + +def test_fails_when_backend_outcomes_differ(tmp_path: Path) -> None: + _write_tests(tmp_path, mismatch=True) + case: Final = _case() + + code, report = _run(case, tmp_path, UnitParitySuite(python_selectors=("test_api.py",))) + + assert code == 1 + assert report.results[case.key].status is RunStatus.FAILED + assert any("Python/Rust test outcomes differ" in detail for _, detail in report.failures) + assert any("Python only: test_api.py::test_backend [call] passed" in detail for _, detail in report.failures) + assert any("Rust only: test_api.py::test_backend [call] failed" in detail for _, detail in report.failures) + + +def test_excludes_tests_whose_contract_is_the_backend_flag(tmp_path: Path) -> None: + _write_tests(tmp_path, mismatch=True) + suite: Final = UnitParitySuite( + python_selectors=("test_api.py",), + exclusions=( + UnitParityExclusion( + nodeid="test_api.py::test_backend", + reason="The test intentionally asserts which backend is selected.", + ), + ), + ) + case: Final = _case() + + code, report = _run(case, tmp_path, suite) + + assert code == 0, report.failures + assert report.results[case.key].status is RunStatus.PASSED diff --git a/tests/rust-python-harness/strategies/unit_tests_rust/AGENTS.md b/tests/rust-python-harness/strategies/unit_tests_rust/AGENTS.md new file mode 100644 index 00000000000..250da763530 --- /dev/null +++ b/tests/rust-python-harness/strategies/unit_tests_rust/AGENTS.md @@ -0,0 +1 @@ +Runs the focused native Cargo test suite for each mapped API. diff --git a/tests/rust-python-harness/strategies/unit_tests_rust/__init__.py b/tests/rust-python-harness/strategies/unit_tests_rust/__init__.py new file mode 100644 index 00000000000..8114e12ab96 --- /dev/null +++ b/tests/rust-python-harness/strategies/unit_tests_rust/__init__.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from collections.abc import Mapping +from functools import partial +from pathlib import Path +from types import MappingProxyType +from typing import Final + +from ...shared.reporting.models import SDK_FUNCTIONS, Coverage, SdkFunction +from ...shared.reporting.strategy import ( + CaseDefinition, + NotImplementedCaseSpec, + StrategyDefinition, + SuiteCaseSpec, +) +from ...shared.unit_runners.suite_runner import run_suites +from ..unit_tests_mapping.mappings import UNIT_TEST_CONTRACTS +from .reporting import render_rust_unit_results +from .runner import RustSuite, run_suite + + +RUST_SUITES: Final[Mapping[SdkFunction, RustSuite]] = MappingProxyType( + { + sdk_function: RustSuite( + cargo_manifest=contract.rust.cargo_manifest, + cargo_filter=contract.rust.cargo_filter, + cargo_package=contract.rust.cargo_package, + ) + for sdk_function, contract in UNIT_TEST_CONTRACTS.items() + } +) + + +CASES: Final[tuple[CaseDefinition, ...]] = ( + *( + CaseDefinition( + sdk_function, + SuiteCaseSpec(coverage=Coverage.COMPLETE, suite=sdk_function) + if sdk_function in RUST_SUITES + else NotImplementedCaseSpec(reason=f"No focused {sdk_function} Rust unit suite is registered."), + ) + for sdk_function in SDK_FUNCTIONS + ), +) + +STRATEGY: Final = StrategyDefinition( + id="unit_tests_rust", + order=32, + label="Unit test Rust", + description="Run the focused native Cargo test suite for each mapped API.", + directory=Path(__file__).parent, + runnable_spec=SuiteCaseSpec, + cases=CASES, + run=partial(run_suites, suites=RUST_SUITES, execute=run_suite), + render=render_rust_unit_results, +) diff --git a/tests/rust-python-harness/strategies/unit_tests_rust/reporting.py b/tests/rust-python-harness/strategies/unit_tests_rust/reporting.py new file mode 100644 index 00000000000..575fa5e8cd1 --- /dev/null +++ b/tests/rust-python-harness/strategies/unit_tests_rust/reporting.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +from collections.abc import Sequence +from typing import Final + +from ...shared.reporting.models import CaseResult +from ...shared.reporting.rendering import ReportSection, render_case_outcome + + +def render_rust_unit_results(results: Sequence[CaseResult]) -> tuple[ReportSection, ...]: + blocks: Final = tuple(render_case_outcome(result) for result in results) + return (ReportSection("Native Rust unit-test outcomes", blocks or ("No Rust unit-test cases selected",)),) diff --git a/tests/rust-python-harness/strategies/unit_tests_rust/runner.py b/tests/rust-python-harness/strategies/unit_tests_rust/runner.py new file mode 100644 index 00000000000..601b5a3c96b --- /dev/null +++ b/tests/rust-python-harness/strategies/unit_tests_rust/runner.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from collections.abc import Sequence +from pathlib import Path + +from pydantic import BaseModel, ConfigDict + +from ...shared.unit_runners.rust_runner import run_rust_tests +from ...shared.unit_runners.suite_runner import SuiteExecution + + +class RustSuite(BaseModel): + model_config = ConfigDict(frozen=True, extra="forbid") + + cargo_manifest: str + cargo_package: str | None = None + cargo_filter: str + + +def run_suite(suite: RustSuite, repo_root: Path, pytest_args: Sequence[str] = ()) -> SuiteExecution: + del pytest_args + if not suite.cargo_filter: + return SuiteExecution(problems=("rust suites must configure a focused Cargo filter",)) + inventory = run_rust_tests( + repo_root / suite.cargo_manifest, suite.cargo_package, suite.cargo_filter, collect_only=True + ) + rust = run_rust_tests(repo_root / suite.cargo_manifest, suite.cargo_package, suite.cargo_filter) + return SuiteExecution( + problems=( + *(("native Rust tests did not all pass",) if set(inventory.tests) != set(rust.tests) else ()), + *((inventory.output,) if inventory.exit_code else ()), + *((rust.output,) if rust.exit_code else ()), + ) + ) diff --git a/tests/rust-python-harness/strategies/unit_tests_rust/test_runner.py b/tests/rust-python-harness/strategies/unit_tests_rust/test_runner.py new file mode 100644 index 00000000000..e151308699a --- /dev/null +++ b/tests/rust-python-harness/strategies/unit_tests_rust/test_runner.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import shutil +from collections.abc import Callable +from pathlib import Path +from typing import Final + +import pytest + +from ...shared.reporting.models import Coverage, HarnessCase, RunStatus +from ...shared.reporting.strategy import SuiteCaseSpec +from ...shared.unit_runners.suite_runner import run_suites +from .runner import RustSuite, run_suite + + +@pytest.mark.skipif(shutil.which("cargo") is None, reason="Cargo is required for the native unit strategy") +def test_runs_cargo_tests_and_propagates_ignored_or_failing_tests( + tmp_path: Path, + cargo_project: Callable[[str, str], Path], +) -> None: + cargo_project("rust-unit-check", '#[test] fn test_decode() { assert_eq!("42".parse::().unwrap(), 42); }\n') + rust_root: Final = tmp_path / "litellm-rust" + rust_root.mkdir() + (tmp_path / "Cargo.toml").rename(rust_root / "Cargo.toml") + (tmp_path / "src").rename(rust_root / "src") + suite: Final = RustSuite( + cargo_manifest="litellm-rust/Cargo.toml", + cargo_filter="test_decode", + ) + case: Final = HarnessCase( + strategy_id="unit_tests_rust", + strategy_label="Unit test Rust", + sdk_function="ocr", + spec=SuiteCaseSpec(coverage=Coverage.COMPLETE, suite="ocr"), + ) + + code, report = run_suites((case,), tmp_path, lambda _: None, suites={"ocr": suite}, execute=run_suite) + + assert code == 0, report.failures + assert report.results[case.key].status is RunStatus.PASSED + + (rust_root / "src/lib.rs").write_text("#[test] #[ignore] fn test_decode() {}\n") + ignored_code, ignored_report = run_suites( + (case,), tmp_path, lambda _: None, suites={"ocr": suite}, execute=run_suite + ) + + assert ignored_code == 1 + assert any("native Rust tests did not all pass" in detail for _, detail in ignored_report.failures) + + (rust_root / "src/lib.rs").write_text("#[test] fn test_decode() { assert_eq!(2 + 2, 5); }\n") + failed_code, failed_report = run_suites((case,), tmp_path, lambda _: None, suites={"ocr": suite}, execute=run_suite) + + assert failed_code == 1 + assert failed_report.results[case.key].status is RunStatus.FAILED + assert any("test_decode" in detail for _, detail in failed_report.failures) diff --git a/tests/sdk_function_trace/README.md b/tests/sdk_function_trace/README.md deleted file mode 100644 index d3a3b654aea..00000000000 --- a/tests/sdk_function_trace/README.md +++ /dev/null @@ -1,30 +0,0 @@ -# SDK function tracing - -The compare runner executes the same SDK calls through the Python engine and the Rust native bridge against a local HTTP provider fixture, then prints their pipeline trees side by side. Matching calls align on the same row in green; Python-only calls are blue, Rust-only calls yellow, and reordered calls red. Gaps preserve execution order and each column retains its own nesting. A comparison column labels every row even without color. Colors are enabled in terminals unless `NO_COLOR` is set. A difference summary follows (shared step order, python-only steps, rust-only steps). Each invocation must issue exactly one HTTP request. It requires the LiteLLM Python dependencies and the native extension built with tracing support - -From the repository root, using the project's Python environment: - -```bash -uv run python -m tests.sdk_function_trace.compare -uv run python -m tests.sdk_function_trace.compare --route ocr -uv run python -m tests.sdk_function_trace.compare --route ocr --sync -uv run python -m tests.sdk_function_trace.compare --route all --both --check -``` - -Calls default to async; use `--sync` for synchronous calls or `--both` for the complete matrix. Python sync Messages raises `not implemented for sync calls`; only that exact failure is marked `SKIP`, and the runner still executes Rust sync Messages and subsequent routes. Bedrock transcription has no independent Python provider implementation: its Python trace covers SDK dispatch into Rust - -Both engines are projected onto a shared per-route step table (`steps.py`): canonical names such as `transform_ocr_request` map Python functions (`MistralOCRConfig.transform_ocr_request`) and Rust spans (`transform_ocr_request`) to the same label. Only the first occurrence of each step is kept. Python indentation uses each event's actual frame ancestors and the nearest already displayed ancestor, so returned helpers and coroutine resumptions do not create false parents. Rust indentation uses instrumented span ancestry. Unmatched Rust span names pass through unchanged. `--full` prints every captured runtime event; validation still uses projected steps - -Every report checks required stage presence and dependency order. Provider lookup must precede request transformation, which must precede HTTP, followed by response transformation. The handler must precede HTTP; parameter mapping and supported-parameter checks must precede request transformation. Environment validation and URL construction, where mapped, must precede HTTP. Python transcription is checked only through native dispatch. `--check` also requires identical canonical step sequences for comparable routes and exits nonzero for missing, extra, or reordered steps, or an unexpected call failure, after finishing all selected cases - -Individual stage checks are separate from cross-language `step parity`. Passing stage checks cannot override a failing step comparison. Bedrock transcription and Python sync Messages report `UNAVAILABLE` for cross-language parity because they lack an independent Python execution to compare. Absolute nesting depth is not a cross-language gate: async Python Messages dispatches its handler onto another thread. See `route-comparison.md` for the audited matrix and remaining contract limitations - -The Python runner uses the existing `profile_python` / `sys.setprofile` collector, selecting executed code under the installed `litellm` source directory instead of maintaining a function-name allowlist. It prints source locations and qualified function names, including repeated calls. Coroutine resumptions are counted once per invocation. It profiles the current thread and threads created during the call, including the fresh async executor. Existing worker threads are not retroactively profiled; background Python calls may appear, and indentation follows selected Python stack ancestors within each thread - -The Rust runner calls the compiled PyO3 SDK entrypoints with `trace=True`. The existing `FunctionTrace` subscriber collects `#[tracing::instrument(target = "litellm::function_trace", level = "trace", skip_all)]` spans for the route entrypoint, preparation, provider lookup, HTTP handler, and selected provider transformations. The shared `http_request` helper instruments the existing Rust send operation without changing clients, timeouts, signing, or error mapping. Function names come from the actual functions. `WithSubscriber` attaches the collector to each future across async polls. Arguments and provider payloads are not recorded in trace events. Uninstrumented functions do not appear; this is scoped instrumentation, not an exhaustive native call graph - -Tracing is opt-in: native calls without `trace=True` keep their original response shape. Traced calls return `{"response": ..., "trace": [{"function": ..., "depth": ...}]}`. The runners print only trace events. Missing native support or empty traces fail instead of falling back to source searching. The old `--repo`, `--signatures`, and `--calls` options are removed - -`profile_python(functions)` still supports direct function references for focused parity checks. `assert_function_trace_parity` compares selected Python events with Rust events supplied by an executable scenario. Successful stage checks prove the declared pipeline ran in a valid dependency order for this fixture; they do not assert identical function contracts, request bodies, responses, streaming behavior, or live-provider correctness - -Build the extension with `maturin develop` in the project's virtual environment. Then run either command above to get the executed function order diff --git a/tests/sdk_function_trace/__init__.py b/tests/sdk_function_trace/__init__.py deleted file mode 100644 index da62b8041f6..00000000000 --- a/tests/sdk_function_trace/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -from tests.sdk_function_trace.harness import ( - TraceScenario, - TraceStep, - assert_function_trace_parity, -) -from tests.sdk_function_trace.profiler import FunctionTraceEvent - -__all__ = [ - "FunctionTraceEvent", - "TraceScenario", - "TraceStep", - "assert_function_trace_parity", -] diff --git a/tests/sdk_function_trace/compare.py b/tests/sdk_function_trace/compare.py deleted file mode 100644 index 941c1b6e067..00000000000 --- a/tests/sdk_function_trace/compare.py +++ /dev/null @@ -1,45 +0,0 @@ -from __future__ import annotations - -import argparse -import os -import sys -from typing import Final - -from tests.sdk_function_trace.fixtures import ROUTES -from tests.sdk_function_trace.report import compare, render - - -def _run(route: str, asynchronous: bool, *, full: bool, colorize: bool) -> bool: - comparison: Final = compare(route, asynchronous=asynchronous) - sys.stdout.write(render(comparison, full=full, colorize=colorize)) - return comparison.passed - - -def main() -> None: - parser: Final = argparse.ArgumentParser(description="Compare Python and Rust SDK pipeline steps per route") - parser.add_argument("--route", choices=("all", *ROUTES), default="all") - mode: Final = parser.add_mutually_exclusive_group() - mode.add_argument("--async", dest="asynchronous", action="store_true", default=True) - mode.add_argument("--sync", dest="asynchronous", action="store_false") - mode.add_argument("--both", action="store_true", help="run async and sync for every selected route") - parser.add_argument( - "--check", action="store_true", help="exit nonzero for missing, extra, or reordered comparable steps" - ) - parser.add_argument( - "--full", action="store_true", help="print every captured runtime event instead of pipeline steps" - ) - args: Final = parser.parse_args() - os.environ.setdefault("LITELLM_LOCAL_MODEL_COST_MAP", "True") - colorize: Final = sys.stdout.isatty() and "NO_COLOR" not in os.environ - results: Final = tuple( - _run(selected, selected_mode, full=args.full, colorize=colorize) - for selected in ROUTES - if args.route in ("all", selected) - for selected_mode in ((True, False) if args.both else (args.asynchronous,)) - ) - if args.check and not all(results): - raise SystemExit(1) - - -if __name__ == "__main__": - main() diff --git a/tests/sdk_function_trace/fixtures.py b/tests/sdk_function_trace/fixtures.py deleted file mode 100644 index 47bbe839627..00000000000 --- a/tests/sdk_function_trace/fixtures.py +++ /dev/null @@ -1,200 +0,0 @@ -from __future__ import annotations - -import base64 -import io -import json -import wave -from collections.abc import Callable -from dataclasses import dataclass -from typing import Final, Protocol, cast - -from tests.sdk_function_trace.mock_provider import MockProviderResponse -from tests.sdk_function_trace.steps import Engine - -ANTHROPIC_MODEL: Final = "claude-sonnet-5" -OCR_MODEL: Final = "mistral-ocr-latest" -AUDIO_MODEL: Final = "mistral.voxtral-mini-3b-2507" - - -class SdkCall(Protocol): - def __call__(self, **kwargs: object) -> object: ... - - -@dataclass(frozen=True, slots=True) -class Fixture: - kwargs: dict[str, object] - provider_response: MockProviderResponse - - -@dataclass(frozen=True, slots=True) -class RouteSpec: - label: str - python_entrypoints: tuple[str, str] - rust_entrypoints: tuple[str, str] - fixture: Callable[[Engine], Fixture] - - -@dataclass(frozen=True, slots=True) -class Invocation: - function: SdkCall - kwargs: dict[str, object] - provider_response: MockProviderResponse - label: str - - -def audio_bytes() -> bytes: - with io.BytesIO() as buffer: - with wave.open(buffer, "wb") as audio: - audio.setnchannels(1) - audio.setsampwidth(2) - audio.setframerate(16000) - audio.writeframes(b"\x00\x00" * 1600) - return buffer.getvalue() - - -def _anthropic_message_response() -> MockProviderResponse: - body: Final = { - "id": "msg_trace", - "type": "message", - "role": "assistant", - "model": ANTHROPIC_MODEL, - "content": [{"type": "text", "text": "hello"}], - "stop_reason": "end_turn", - "stop_sequence": None, - "usage": {"input_tokens": 2, "output_tokens": 3}, - } - return MockProviderResponse(200, (("content-type", "application/json"),), json.dumps(body).encode()) - - -def _conversation() -> dict[str, object]: - return {"messages": [{"role": "user", "content": "hello"}], "max_tokens": 16} - - -def _ocr_fixture(engine: Engine) -> Fixture: - return Fixture( - kwargs={ - "model": f"mistral/{OCR_MODEL}", - "document": {"type": "document_url", "document_url": "https://example.com/document.pdf"}, - **({"optional_params": {"pages": [0]}} if engine == "rust" else {"pages": [0]}), - }, - provider_response=MockProviderResponse( - 200, - (("content-type", "application/json"),), - json.dumps( - { - "pages": [{"index": 0, "markdown": "hello"}], - "model": OCR_MODEL, - "usage_info": {"pages_processed": 1}, - } - ).encode(), - ), - ) - - -def _chat_completions_fixture(engine: Engine) -> Fixture: - conversation: Final = _conversation() - payload: Final = ( - {"messages": conversation["messages"], "optional_params": {"max_tokens": 16}} - if engine == "rust" - else conversation - ) - return Fixture( - kwargs={"model": f"anthropic/{ANTHROPIC_MODEL}", **payload}, - provider_response=_anthropic_message_response(), - ) - - -def _messages_fixture(engine: Engine) -> Fixture: - conversation: Final = _conversation() - payload: Final = {"body": {**conversation, "model": ANTHROPIC_MODEL}} if engine == "rust" else conversation - return Fixture( - kwargs={"model": f"anthropic/{ANTHROPIC_MODEL}", **payload}, - provider_response=_anthropic_message_response(), - ) - - -def _transcription_fixture(engine: Engine) -> Fixture: - credentials: Final = { - "aws_access_key_id": "test-access", - "aws_secret_access_key": "test-secret", - "aws_region_name": "us-east-1", - } - payload: Final = ( - { - "audio": {"data": base64.b64encode(audio_bytes()).decode(), "format": "wav"}, - "optional_params": credentials, - } - if engine == "rust" - else {"file": ("sample.wav", audio_bytes(), "audio/wav"), **credentials} - ) - return Fixture( - kwargs={"model": f"bedrock/{AUDIO_MODEL}", **payload}, - provider_response=MockProviderResponse( - 200, - (("content-type", "application/json"),), - json.dumps( - { - "output": {"message": {"role": "assistant", "content": [{"text": "hello"}]}}, - "stopReason": "end_turn", - "usage": {"inputTokens": 2, "outputTokens": 3, "totalTokens": 5}, - } - ).encode(), - ), - ) - - -ROUTE_SPECS: Final[dict[str, RouteSpec]] = { - "chat_completions": RouteSpec( - label="anthropic", - python_entrypoints=("completion", "acompletion"), - rust_entrypoints=("chat_completions", "achat_completions"), - fixture=_chat_completions_fixture, - ), - "audio_transcription": RouteSpec( - label="bedrock (Rust-only provider; Python trace covers SDK dispatch)", - python_entrypoints=("transcription", "atranscription"), - rust_entrypoints=("transcription", "atranscription"), - fixture=_transcription_fixture, - ), - "messages": RouteSpec( - label="anthropic", - python_entrypoints=("create", "acreate"), - rust_entrypoints=("messages", "amessages"), - fixture=_messages_fixture, - ), - "ocr": RouteSpec( - label="mistral", - python_entrypoints=("ocr", "aocr"), - rust_entrypoints=("ocr", "aocr"), - fixture=_ocr_fixture, - ), -} - -ROUTES: Final = tuple(ROUTE_SPECS) - - -def sdk_invocation(route: str, *, engine: Engine, asynchronous: bool) -> Invocation: - import litellm - from litellm.anthropic_interface import messages as sdk_messages - from litellm.rust_bridge import get_native_bridge - - rust: Final = engine == "rust" - bridge: Final = get_native_bridge() if rust else None - if rust and bridge is None: - raise RuntimeError("Build the native extension first: maturin develop") - spec: Final = ROUTE_SPECS.get(route) - if spec is None: - raise ValueError(f"Unknown route: {route}") - fixture: Final = spec.fixture(engine) - owner: Final = bridge if rust else (sdk_messages if route == "messages" else litellm) - entrypoint: Final = (spec.rust_entrypoints if rust else spec.python_entrypoints)[int(asynchronous)] - return Invocation( - function=cast(SdkCall, getattr(owner, entrypoint)), - kwargs={ - **fixture.kwargs, - "api_key": "test-key", - **({"trace": True, "timeout_seconds": 5} if rust else {"timeout": 5}), - }, - provider_response=fixture.provider_response, - label=spec.label, - ) diff --git a/tests/sdk_function_trace/harness.py b/tests/sdk_function_trace/harness.py deleted file mode 100644 index 8f707402449..00000000000 --- a/tests/sdk_function_trace/harness.py +++ /dev/null @@ -1,39 +0,0 @@ -from __future__ import annotations - -from collections.abc import Callable, Sequence -from dataclasses import dataclass -from types import FunctionType -from typing import Final, cast - -from tests.sdk_function_trace.profiler import FunctionTraceEvent, profile_python - - -@dataclass(frozen=True, slots=True) -class TraceStep: - function: FunctionType - depth: int - - -@dataclass(frozen=True, slots=True) -class TraceScenario: - steps: tuple[TraceStep, ...] - invoke_python: Callable[[], object] - invoke_rust: Callable[[], Sequence[FunctionTraceEvent]] - - -def assert_function_trace_parity(scenario: TraceScenario) -> None: - expected: Final = tuple( - FunctionTraceEvent(function=step.function.__name__, depth=step.depth) for step in scenario.steps - ) - functions: Final = cast(tuple[FunctionType, ...], tuple(step.function for step in scenario.steps)) - with profile_python(functions) as profiler: - scenario.invoke_python() - python_trace: Final = tuple(profiler.events) - rust_trace: Final = tuple(scenario.invoke_rust()) - - if python_trace != expected: - raise AssertionError(f"Python function trace differs: {python_trace!r} != {expected!r}") - if rust_trace != expected: - raise AssertionError(f"Rust function trace differs: {rust_trace!r} != {expected!r}") - if python_trace != rust_trace: - raise AssertionError(f"Python and Rust function traces differ: {python_trace!r} != {rust_trace!r}") diff --git a/tests/sdk_function_trace/mock_provider.py b/tests/sdk_function_trace/mock_provider.py deleted file mode 100644 index 37eca665586..00000000000 --- a/tests/sdk_function_trace/mock_provider.py +++ /dev/null @@ -1,67 +0,0 @@ -from __future__ import annotations - -from collections.abc import Generator -from contextlib import contextmanager -from dataclasses import dataclass -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -from threading import Lock, Thread -from typing import Final, cast - - -@dataclass(frozen=True, slots=True) -class MockProviderResponse: - status_code: int - headers: tuple[tuple[str, str], ...] - body: bytes - - -class _MockProviderServer(ThreadingHTTPServer): - def __init__(self, response: MockProviderResponse) -> None: - super().__init__(("127.0.0.1", 0), _MockProviderHandler) - self.response: Final = response - self._request_count = 0 - self._request_count_lock: Final = Lock() - - def record_request(self) -> None: - with self._request_count_lock: - self._request_count += 1 - - @property - def request_count(self) -> int: - with self._request_count_lock: - return self._request_count - - -class _MockProviderHandler(BaseHTTPRequestHandler): - protocol_version = "HTTP/1.1" - - def do_POST(self) -> None: - content_length: Final = int(self.headers.get("content-length", "0")) - self.rfile.read(content_length) - server: Final = cast(_MockProviderServer, self.server) - server.record_request() - self.send_response(server.response.status_code) - for name, value in server.response.headers: - self.send_header(name, value) - self.send_header("content-length", str(len(server.response.body))) - self.end_headers() - self.wfile.write(server.response.body) - - def log_message(self, format: str, *args: object) -> None: # noqa: A002 # matches BaseHTTPRequestHandler - pass - - -@contextmanager -def mock_provider(response: MockProviderResponse) -> Generator[str]: - server: Final = _MockProviderServer(response) - thread: Final = Thread(target=server.serve_forever, daemon=True) - thread.start() - host, port = cast(tuple[str, int], server.server_address) - try: - yield f"http://{host}:{port}" - finally: - server.shutdown() - server.server_close() - thread.join() - if server.request_count != 1: - raise AssertionError(f"expected one provider request, received {server.request_count}") diff --git a/tests/sdk_function_trace/ocr-comparison.md b/tests/sdk_function_trace/ocr-comparison.md deleted file mode 100644 index d252480e218..00000000000 --- a/tests/sdk_function_trace/ocr-comparison.md +++ /dev/null @@ -1,59 +0,0 @@ -# OCR Python and Rust comparison - -Audited implementation revision: `edcba483b2`. The implementations do not match in function contracts, call structure, or all tested response behavior. This audit changes the source listing coverage, not OCR runtime behavior - -Run both source listings from the repository root: - -```bash -python3 tests/sdk_function_trace/list_python_steps.py --route ocr --signatures --calls -uv run tests/sdk_function_trace/list_rust_steps.py --route ocr --signatures --calls -``` - -Both cover Mistral, Azure AI Mistral, Azure Document Intelligence, Vertex Mistral, and Vertex DeepSeek. Listings show declarations and source call sites, not executed traces - -## Function contracts - -Comparing Python `BaseOCRConfig` with Rust `OcrProviderConfig`, omitting `self` and language-specific ownership details: - -| Python | Rust | Difference | -| --- | --- | --- | -| `get_supported_ocr_params(model)` | `supported_ocr_params()` | Name and model argument | -| `get_api_key_env_var()` | No corresponding method | Missing contract | -| `map_ocr_params(non_default_params, optional_params, model)` | `map_ocr_params(non_default_params)` | Missing accumulator and model | -| `validate_environment(headers, model, api_key, api_base, litellm_params, **kwargs)` | Separate auth/key/header helpers | Different contract | -| `get_complete_url(api_base, model, optional_params, litellm_params, **kwargs)` | `complete_url(api_base, model, optional_params, env_lookup)` | Name and context | -| `transform_ocr_request(model, document, optional_params, headers, **kwargs)` | `transform_ocr_request(model, document, optional_params)` | Missing headers and extra context | -| `async_transform_ocr_request(...)` | No corresponding method | Missing async override | -| `transform_ocr_response(model, raw_response, logging_obj, **kwargs)` | `transform_ocr_response(model, response_json)` | Missing HTTP metadata, logging and extra context | -| `async_transform_ocr_response(...)` | No corresponding method | Missing async override | -| `get_error_class(error_message, status_code, headers)` | Central Rust error mapping | Different contract | - -Python's default mapper returns the supplied `optional_params`; Rust's filters `non_default_params`. Provider overrides must also be compared - -Python maps parameters during SDK preparation, before HTTP-handler environment validation and URL construction. Rust resolves auth and URL before mapping parameters in `prepare_provider_request`. Python has async provider transforms; both native entrypoints execute the same Rust async route using synchronous transform hooks, with polling and document downloading in gateway helpers - -The native bindings also accept `optional_params` and `timeout_seconds`, while the Python SDK accepts `**kwargs` and `timeout`. Public SDK calls with Rust enabled still execute Python preparation before entering Rust, so matching SDK responses would not prove matching standalone Rust steps - -## Runtime results - -Built the native extension from the audited source using `cargo build -p litellm-python-bridge --features extension-module --offline`. Supplied that build's functions through `use_litellm_rust` dependency injection. Ran public `litellm.ocr` and `litellm.aocr` with Rust disabled and enabled against identical local HTTP response fixtures, requiring one request per invocation - -Successful `model_dump()` results and failure exception classes were compared. These checks cover Mistral response outcomes only, not request equality, error messages, live providers, or every execution branch - -| Mistral response fixture | Sync | Async | Observation | -| --- | --- | --- | --- | -| Valid page/model/usage | Match | Match | Same normalized response | -| Model omitted | Match | Match | Both use the requested model | -| `model: null` | Different | Different | Python rejects; Rust uses the requested model | -| `pages: null` | Different | Different | Python rejects; Rust returns an empty array | -| Invalid page element | Match | Match | Both reject during response validation | - -Six of ten fixture/mode comparisons match, four differ. Rust's Mistral response transform conflates missing values with explicit nulls through `as_array`/`as_str` fallbacks. Python preserves explicit nulls into response validation, which rejects them - -## Other provider gaps found in source - -Azure Document Intelligence's Python configuration supports `pages`, `features`, and `req_format`; Rust lists only `pages`. Python normalizes parameters before URL construction; Rust normalizes pages during URL construction - -Python preserves Azure `content`, `tables`, and `keyValuePairs`, and supports retaining the native operation payload. Rust's `OcrResponseData` has no corresponding fields, and its Azure transform does not preserve those values - -Azure and Vertex async document transforms and Azure polling also use different helper contracts. Their runtime equivalence was not tested in this audit diff --git a/tests/sdk_function_trace/profiler.py b/tests/sdk_function_trace/profiler.py deleted file mode 100644 index c71c74ab0d3..00000000000 --- a/tests/sdk_function_trace/profiler.py +++ /dev/null @@ -1,76 +0,0 @@ -from __future__ import annotations - -import sys -import threading -from collections.abc import Generator, Sequence -from contextlib import contextmanager -from dataclasses import dataclass -from pathlib import Path -from types import CodeType, FrameType, FunctionType -from typing import Final - - -@dataclass(frozen=True, slots=True) -class FunctionTraceEvent: - function: str - depth: int - ancestors: tuple[str, ...] | None = None - - -class PythonProfiler: - def __init__(self, functions: Sequence[FunctionType], source_root: Path | None = None) -> None: - self._source_root: Final = str(source_root.resolve()) + "/" if source_root is not None else None - self._names_by_code: Final = {function.__code__: function.__name__ for function in functions} - self._seen_frames: Final[set[FrameType]] = set() - self.events: Final[list[FunctionTraceEvent]] = [] - - def __call__(self, frame: FrameType, event: str, _arg: object) -> None: - if event != "call" or frame in self._seen_frames: - return - function_name: Final = self.function_name(frame.f_code) - if function_name is None: - return - ancestors: Final = tuple( - name for ancestor in _frame_ancestors(frame) if (name := self.function_name(ancestor.f_code)) is not None - ) - self._seen_frames.add(frame) - self.events.append( - FunctionTraceEvent( - function=function_name, - depth=len(ancestors), - ancestors=ancestors if self._source_root is not None else None, - ) - ) - - def function_name(self, code: CodeType) -> str | None: - if self._source_root is None: - return self._names_by_code.get(code) - if not code.co_filename.startswith(self._source_root): - return None - relative: Final = code.co_filename.removeprefix(self._source_root) - return f"{relative}:{code.co_firstlineno} {getattr(code, 'co_qualname', code.co_name)}" - - -def _frame_ancestors(frame: FrameType) -> Generator[FrameType]: - ancestor: Final = frame.f_back - if ancestor is not None: - yield ancestor - yield from _frame_ancestors(ancestor) - - -@contextmanager -def profile_python( - functions: Sequence[FunctionType] = (), *, source_root: Path | None = None, threads: bool = False -) -> Generator[PythonProfiler]: - profiler: Final = PythonProfiler(functions, source_root) - previous_thread: Final = threading.getprofile() - if threads: - threading.setprofile(profiler) - previous: Final = sys.getprofile() - sys.setprofile(profiler) - try: - yield profiler - finally: - sys.setprofile(previous) - if threads: - threading.setprofile(previous_thread) diff --git a/tests/sdk_function_trace/report.py b/tests/sdk_function_trace/report.py deleted file mode 100644 index 9b654e571f8..00000000000 --- a/tests/sdk_function_trace/report.py +++ /dev/null @@ -1,175 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from typing import Final - -from tests.sdk_function_trace.fixtures import ROUTE_SPECS -from tests.sdk_function_trace.profiler import FunctionTraceEvent -from tests.sdk_function_trace.runtime import ( - TraceDiff, - TraceFailed, - TraceOk, - TraceRun, - TraceSkipped, - attempt_trace, - trace_diff, -) -from tests.sdk_function_trace.steps import Engine, pipeline_issues, pipeline_steps -from tests.sdk_function_trace.table import format_trace_table - -_PYTHON_ONLY_COLOR: Final = "\033[34m" -_RUST_ONLY_COLOR: Final = "\033[33m" -_RESET: Final = "\033[0m" - -_ENGINE_COLOR: Final[dict[Engine, str]] = {"python": _PYTHON_ONLY_COLOR, "rust": _RUST_ONLY_COLOR} - - -@dataclass(frozen=True, slots=True) -class EngineReport: - engine: Engine - run: TraceRun - events: tuple[FunctionTraceEvent, ...] - steps: tuple[FunctionTraceEvent, ...] - issues: tuple[str, ...] - - -@dataclass(frozen=True, slots=True) -class Comparison: - route: str - label: str - asynchronous: bool - engines: tuple[EngineReport, ...] - diff: TraceDiff - - @property - def comparable(self) -> bool: - return self.route != "audio_transcription" and all(isinstance(report.run, TraceOk) for report in self.engines) - - @property - def passed(self) -> bool: - return ( - (not self.comparable or self.diff.matches) - and not any(report.issues for report in self.engines) - and all(not isinstance(report.run, TraceFailed) for report in self.engines) - ) - - -def _events(run: TraceRun) -> tuple[FunctionTraceEvent, ...]: - match run: - case TraceOk(events=events): - return events - case TraceSkipped() | TraceFailed(): - return () - - -def _engine_report(route: str, engine: Engine, run: TraceRun) -> EngineReport: - events: Final = _events(run) - steps: Final = pipeline_steps(route, engine, events) - issues: Final = pipeline_issues(route, engine, steps) if isinstance(run, TraceOk) else () - return EngineReport(engine=engine, run=run, events=events, steps=steps, issues=issues) - - -def compare(route: str, *, asynchronous: bool) -> Comparison: - runs: Final = { - engine: attempt_trace(route, engine=engine, asynchronous=asynchronous) for engine in ("python", "rust") - } - engines: Final = tuple(_engine_report(route, engine, run) for engine, run in runs.items()) - return Comparison( - route=route, - label=ROUTE_SPECS[route].label, - asynchronous=asynchronous, - engines=engines, - diff=trace_diff(engines[0].steps, engines[1].steps), - ) - - -def _tree_line(event: FunctionTraceEvent, only: frozenset[str], marker: str, color: str, *, colorize: bool) -> str: - line: Final = f"{' ' * event.depth}{event.function}" + (f" {marker}" if event.function in only else "") - return f"{color}{line}{_RESET}\n" if colorize and event.function in only else f"{line}\n" - - -def _tree_lines( - events: tuple[FunctionTraceEvent, ...], - only: frozenset[str], - marker: str, - color: str, - *, - colorize: bool, -) -> tuple[str, ...]: - return tuple(_tree_line(event, only, marker, color, colorize=colorize) for event in events) - - -def _engine_lines( - report: EngineReport, diff: TraceDiff, *, comparable: bool, full: bool, colorize: bool -) -> tuple[str, ...]: - match report.run: - case TraceSkipped(reason=reason): - return (f"{report.engine}: SKIP ({reason})\n\n",) - case TraceFailed(reason=reason): - return (f"{report.engine}: FAIL ({reason})\n\n",) - case TraceOk(): - shown: Final = report.events if full else report.steps - only: Final = ( - () if full or not comparable else (diff.python_only if report.engine == "python" else diff.rust_only) - ) - return ( - f"{report.engine} ({len(shown)} steps)\n\n", - *_tree_lines( - shown, - frozenset(only), - f"<- {report.engine} only", - _ENGINE_COLOR[report.engine], - colorize=colorize, - ), - "\n", - ) - - -def _parity_lines(comparison: Comparison) -> tuple[str, ...]: - if not comparison.comparable: - if comparison.route == "audio_transcription": - return ("step parity: UNAVAILABLE (Bedrock transcription has no independent Python implementation)\n",) - return ("step parity: UNAVAILABLE (both engines must complete)\n",) - diff: Final = comparison.diff - order: Final = "the same" if diff.shared_order_matches else "a different" - return ( - "diff\n\n", - f"shared steps appear in {order} order\n", - f"python-only: {', '.join(diff.python_only) or 'none'}\n", - f"rust-only: {', '.join(diff.rust_only) or 'none'}\n\n", - f"step parity: {'PASS' if diff.matches else 'FAIL'}\n", - ) - - -def _stage_lines(comparison: Comparison) -> tuple[str, ...]: - return tuple( - f"{report.engine} " - f"{'SDK dispatch only' if comparison.route == 'audio_transcription' and report.engine == 'python' else 'pipeline'}: " - f"{'FAIL: ' + '; '.join(report.issues) if report.issues else 'PASS'}\n" - for report in comparison.engines - if isinstance(report.run, TraceOk) - ) - - -def render(comparison: Comparison, *, full: bool, colorize: bool) -> str: - mode: Final = "async" if comparison.asynchronous else "sync" - traces: Final = ( - (format_trace_table(comparison.engines[0].steps, comparison.engines[1].steps, colorize=colorize) + "\n\n",) - if not full and all(isinstance(report.run, TraceOk) for report in comparison.engines) - else tuple( - line - for report in comparison.engines - for line in _engine_lines( - report, comparison.diff, comparable=comparison.comparable, full=full, colorize=colorize - ) - ) - ) - return "".join( - ( - f"route: {comparison.route} provider: {comparison.label} mode: {mode}\n\n", - *traces, - *_parity_lines(comparison), - *_stage_lines(comparison), - "Each successful invocation issued exactly one local provider request\n\n", - ) - ) diff --git a/tests/sdk_function_trace/route-comparison.md b/tests/sdk_function_trace/route-comparison.md deleted file mode 100644 index 009d3544d05..00000000000 --- a/tests/sdk_function_trace/route-comparison.md +++ /dev/null @@ -1,26 +0,0 @@ -# SDK route trace audit - -Run the four native HTTP route families in both modes from the repository root: - -```bash -uv run python -m tests.sdk_function_trace.compare --route all --both --check -``` - -The local fixture matrix on 2026-09-02 completed 15 successful engine invocations and one expected skip. Every successful invocation issued exactly one local HTTP request. All five comparable route/mode pairs have identical canonical steps in the same order, with no Python-only or Rust-only steps - -| Route | Python async | Python sync | Rust async | Rust sync | -| --- | --- | --- | --- | --- | -| Chat completions, Anthropic | Pass | Pass | Pass | Pass | -| Messages, Anthropic | Pass | Unsupported, skipped | Pass | Pass | -| OCR, Mistral | Pass | Pass | Pass | Pass | -| Audio transcription, Bedrock | Dispatch only | Dispatch only | Pass | Pass | - -The same canonical step sequence ran in sync and async for each engine with both modes available. Bedrock transcription's Python SDK delegates to Rust, so its two successful calls do not establish independent provider parity. Realtime and Responses WebSockets are outside this HTTP fixture runner - -Chat and OCR also have identical projected nesting in both modes. Async Messages has the same helper nesting beneath its handler, but Python starts that handler on a worker thread, so it appears as a second root. The comparison preserves this physical thread boundary and checks step order independently of absolute depth - -Rust now resolves chat providers and supported parameters before entering its handler. Chat and Messages validate the environment and transform requests inside their handlers. Messages builds the final URL after transformation. OCR resolves its config and maps supported parameters during preparation, then validates credentials, builds the URL, and transforms the request inside its handler. Its during-call guardrails still run before HTTP, within the provider-call lifecycle phase - -The environment hooks execute credential and header validation. Chat's supported-parameter hooks return OpenAI names paired with provider names and feed the existing request acceptance checks. The direct Rust API still accepts provider-mapped parameters, and its supported subset is smaller than Python's. Matching the pipeline does not establish identical parameter contracts - -`--check` now fails if either comparable engine has missing, extra, or reordered canonical steps, even if its individual stage checks pass. Bedrock transcription and sync Messages report `UNAVAILABLE` for cross-language parity; native execution is still checked. Passing establishes step coverage and order for one non-streaming fixture per route, not complete request, response, error, or provider parity. The previously recorded OCR response gaps remain in `ocr-comparison.md` diff --git a/tests/sdk_function_trace/runtime.py b/tests/sdk_function_trace/runtime.py deleted file mode 100644 index d5bf15694bc..00000000000 --- a/tests/sdk_function_trace/runtime.py +++ /dev/null @@ -1,128 +0,0 @@ -from __future__ import annotations - -import asyncio -import os -from collections.abc import Awaitable, Generator -from contextlib import contextmanager -from dataclasses import dataclass -from pathlib import Path -from typing import Final, cast -from unittest.mock import patch - -from pydantic import BaseModel, ConfigDict - -from tests.sdk_function_trace.fixtures import Invocation, sdk_invocation -from tests.sdk_function_trace.mock_provider import mock_provider -from tests.sdk_function_trace.profiler import FunctionTraceEvent, profile_python -from tests.sdk_function_trace.steps import Engine - - -class TraceEventPayload(BaseModel): - model_config = ConfigDict(strict=True, extra="forbid") - function: str - depth: int - - -class TraceResponsePayload(BaseModel): - model_config = ConfigDict(strict=True, extra="forbid") - response: object - trace: tuple[TraceEventPayload, ...] | list[TraceEventPayload] - - -@contextmanager -def _python_engine() -> Generator[None]: - from litellm.rust_bridge import ocr as ocr_bridge - - previous_ocr: Final = ocr_bridge.rust_ocr_enabled() - with patch.dict(os.environ, {"LITELLM_RUST": "false"}): - ocr_bridge.use_litellm_rust(False) - try: - yield - finally: - ocr_bridge.use_litellm_rust(previous_ocr) - - -def _invoke(case: Invocation, api_base: str, *, asynchronous: bool) -> object: - async def invoke_async() -> object: - return await cast("Awaitable[object]", case.function(**case.kwargs, api_base=api_base)) - - if asynchronous: - return asyncio.run(invoke_async()) - return case.function(**case.kwargs, api_base=api_base) - - -def collect(case: Invocation, api_base: str, *, engine: Engine, asynchronous: bool) -> tuple[FunctionTraceEvent, ...]: - import litellm - - if engine == "rust": - payload: Final = TraceResponsePayload.model_validate(_invoke(case, api_base, asynchronous=asynchronous)) - return tuple(FunctionTraceEvent(event.function, event.depth) for event in payload.trace) - with profile_python(source_root=Path(litellm.__file__).parent, threads=True) as profiler: - _invoke(case, api_base, asynchronous=asynchronous) - return tuple(profiler.events) - - -def run_trace(route: str, *, engine: Engine, asynchronous: bool = False) -> tuple[FunctionTraceEvent, ...]: - case: Final = sdk_invocation(route, engine=engine, asynchronous=asynchronous) - with _python_engine(), mock_provider(case.provider_response) as api_base: - events: Final = collect(case, api_base, engine=engine, asynchronous=asynchronous) - if not events: - raise RuntimeError(f"No runtime events for {route}; rebuild the native extension with tracing support") - return events - - -@dataclass(frozen=True, slots=True) -class TraceOk: - events: tuple[FunctionTraceEvent, ...] - - -@dataclass(frozen=True, slots=True) -class TraceSkipped: - reason: str - - -@dataclass(frozen=True, slots=True) -class TraceFailed: - reason: str - - -TraceRun = TraceOk | TraceSkipped | TraceFailed - - -def attempt_trace(route: str, *, engine: Engine, asynchronous: bool) -> TraceRun: - try: - return TraceOk(run_trace(route, engine=engine, asynchronous=asynchronous)) - except Exception as error: - reason: Final = f"{type(error).__name__}: {error}" - if ( - route == "messages" - and engine == "python" - and not asynchronous - and isinstance(error, ValueError) - and str(error) == "anthropic_messages_handler is not implemented for sync calls" - ): - return TraceSkipped(reason) - return TraceFailed(reason) - - -@dataclass(frozen=True, slots=True) -class TraceDiff: - python_only: tuple[str, ...] - rust_only: tuple[str, ...] - shared_order_matches: bool - - @property - def matches(self) -> bool: - return not self.python_only and not self.rust_only and self.shared_order_matches - - -def trace_diff(python: tuple[FunctionTraceEvent, ...], rust: tuple[FunctionTraceEvent, ...]) -> TraceDiff: - python_names: Final = {event.function for event in python} - rust_names: Final = {event.function for event in rust} - shared_python: Final = tuple(event.function for event in python if event.function in rust_names) - shared_rust: Final = tuple(event.function for event in rust if event.function in python_names) - return TraceDiff( - python_only=tuple(event.function for event in python if event.function not in rust_names), - rust_only=tuple(event.function for event in rust if event.function not in python_names), - shared_order_matches=bool(shared_python) and shared_python == shared_rust, - ) diff --git a/tests/sdk_function_trace/steps.py b/tests/sdk_function_trace/steps.py deleted file mode 100644 index bb50d4ebe57..00000000000 --- a/tests/sdk_function_trace/steps.py +++ /dev/null @@ -1,181 +0,0 @@ -from __future__ import annotations - -import re -from collections.abc import Sequence -from dataclasses import dataclass -from functools import reduce -from typing import Final, Literal - -from tests.sdk_function_trace.profiler import FunctionTraceEvent - -Engine = Literal["python", "rust"] - - -@dataclass(frozen=True, slots=True) -class Step: - name: str - python: re.Pattern[str] | None - rust: str | None - - -def _step(name: str, python: str | None = None, rust: str | None = None) -> Step: - return Step(name, re.compile(python) if python is not None else None, rust) - - -_POST: Final = r"AsyncHTTPHandler\.post$|HTTPHandler\.post$" - -STEPS: Final[dict[str, tuple[Step, ...]]] = { - "ocr": ( - _step("ocr", r"ocr/main\.py:\d+ a?ocr$", "ocr"), - _step("prepare_ocr_call", r"ocr/main\.py:\d+ _prepare_ocr_request$", "prepare_ocr_call"), - _step("get_provider_ocr_config", r"ProviderConfigManager\.get_provider_ocr_config$", "ocr_provider_config"), - _step("supported_ocr_params", r"get_supported_ocr_params$", "supported_ocr_params"), - _step("map_ocr_params", r"(? tuple[str, ...]: - names: Final = tuple(event.function for event in events) - required: Final = tuple(step.name for step in STEPS[route] if getattr(step, engine) is not None) - missing: Final = tuple(f"missing {name}" for name in required if name not in names) - provider: Final = next(name for name in required if name.startswith("get_provider_")) - handler: Final = next(name for name in required if name.startswith("execute_")) - dispatch_only: Final = route == "audio_transcription" and engine == "python" - request: Final = next( - (name for name in required if name.startswith("transform_") and name.endswith("request")), handler - ) - response: Final = next( - (name for name in required if name.startswith("transform_") and name.endswith("response")), handler - ) - phases: Final = ( - (route, "map_transcription_params", provider, handler) - if dispatch_only - else (route, provider, request, "http_request", response) - ) - extra_edges: Final = ( - () - if dispatch_only - else ( - (handler, "http_request"), - *((name, request) for name in required if name.startswith(("map_", "supported_"))), - *((name, "http_request") for name in ("validate_environment", "complete_url") if name in required), - ) - ) - edges: Final = (*zip(phases, phases[1:]), *extra_edges) - return missing + tuple( - f"{before} must precede {after}" - for before, after in edges - if before in names and after in names and names.index(before) >= names.index(after) - ) - - -def _canonical_name(route: str, engine: Engine, function: str) -> str | None: - for step in STEPS[route]: - if engine == "python": - if step.python is not None and step.python.search(function): - return step.name - elif step.rust is not None and function == step.rust: - return step.name - return function if engine == "rust" else None - - -@dataclass(frozen=True, slots=True) -class _Projection: - shown: tuple[FunctionTraceEvent, ...] = () - stack: tuple[tuple[int, int], ...] = () - seen: frozenset[str] = frozenset() - - -def _project(route: str, engine: Engine, state: _Projection, event: FunctionTraceEvent) -> _Projection: - stack: Final = tuple(pair for pair in state.stack if event.depth > pair[0]) - name: Final = _canonical_name(route, engine, event.function) - if name is None or name in state.seen: - return _Projection(state.shown, stack, state.seen) - depth: Final = ( - next( - ( - kept.depth + 1 - for ancestor in event.ancestors - for kept in state.shown - if kept.function == _canonical_name(route, engine, ancestor) - ), - 0, - ) - if event.ancestors is not None - else stack[-1][1] + 1 - if stack - else 0 - ) - return _Projection( - state.shown + (FunctionTraceEvent(function=name, depth=depth),), - stack + ((event.depth, depth),), - state.seen | {name}, - ) - - -def pipeline_steps(route: str, engine: Engine, events: Sequence[FunctionTraceEvent]) -> tuple[FunctionTraceEvent, ...]: - projection: Final = reduce(lambda state, event: _project(route, engine, state, event), events, _Projection()) - return projection.shown diff --git a/tests/sdk_function_trace/table.py b/tests/sdk_function_trace/table.py deleted file mode 100644 index 2124d7e3faf..00000000000 --- a/tests/sdk_function_trace/table.py +++ /dev/null @@ -1,72 +0,0 @@ -from __future__ import annotations - -from collections.abc import Iterator -from difflib import SequenceMatcher -from typing import Final - -from tests.sdk_function_trace.profiler import FunctionTraceEvent - - -def _aligned_rows( - python: tuple[FunctionTraceEvent, ...], rust: tuple[FunctionTraceEvent, ...] -) -> Iterator[tuple[FunctionTraceEvent | None, FunctionTraceEvent | None]]: - matcher: Final = SequenceMatcher( - a=tuple(event.function for event in python), - b=tuple(event.function for event in rust), - autojunk=False, - ) - for tag, python_start, python_end, rust_start, rust_end in matcher.get_opcodes(): - if tag == "equal": - yield from zip(python[python_start:python_end], rust[rust_start:rust_end]) - else: - yield from ((event, None) for event in python[python_start:python_end]) - yield from ((None, event) for event in rust[rust_start:rust_end]) - - -def _label(event: FunctionTraceEvent | None) -> str: - return f"{' ' * event.depth}{event.function}" if event is not None else "" - - -def _status( - python: FunctionTraceEvent | None, - rust: FunctionTraceEvent | None, - python_names: frozenset[str], - rust_names: frozenset[str], -) -> tuple[str, str]: - if python is not None and rust is not None: - return "match", "\033[32m" - if python is not None: - return ("reordered", "\033[31m") if python.function in rust_names else ("python only", "\033[34m") - if rust is not None: - return ("reordered", "\033[31m") if rust.function in python_names else ("rust only", "\033[33m") - return "", "" - - -def format_trace_table( - python: tuple[FunctionTraceEvent, ...], - rust: tuple[FunctionTraceEvent, ...], - *, - colorize: bool, -) -> str: - python_header: Final = f"python ({len(python)} steps)" - rust_header: Final = f"rust ({len(rust)} steps)" - python_width: Final = max(len(python_header), *(len(_label(event)) for event in python), 0) - rust_width: Final = max(len(rust_header), *(len(_label(event)) for event in rust), 0) - python_names: Final = frozenset(event.function for event in python) - rust_names: Final = frozenset(event.function for event in rust) - border: Final = f"+-{'-' * python_width}-+-{'-' * rust_width}-+-------------+" - rows: Final = tuple( - f"{color}{line}\033[0m" if colorize else line - for left, right in _aligned_rows(python, rust) - for status, color in (_status(left, right, python_names, rust_names),) - for line in (f"| {_label(left):<{python_width}} | {_label(right):<{rust_width}} | {status:<11} |",) - ) - return "\n".join( - ( - border, - f"| {python_header:<{python_width}} | {rust_header:<{rust_width}} | {'comparison':<11} |", - border, - *rows, - border, - ) - ) diff --git a/tests/sdk_function_trace/test_mock_provider.py b/tests/sdk_function_trace/test_mock_provider.py deleted file mode 100644 index 88d7d5392d0..00000000000 --- a/tests/sdk_function_trace/test_mock_provider.py +++ /dev/null @@ -1,33 +0,0 @@ -from __future__ import annotations - -from contextlib import ExitStack -from typing import Final -from urllib.error import HTTPError -from urllib.request import Request, urlopen - -import pytest - -from tests.sdk_function_trace.mock_provider import MockProviderResponse, mock_provider - - -def test_mock_provider_preserves_error_response() -> None: - response: Final = MockProviderResponse(429, (("retry-after", "2"),), b'{"error":"rate limited"}') - with mock_provider(response) as api_base: - with pytest.raises(HTTPError) as error: - urlopen(Request(api_base, data=b"{}"), timeout=5) - with error.value as received: - assert received.code == 429 - assert received.headers["retry-after"] == "2" - assert received.read() == response.body - - -@pytest.mark.parametrize("request_count", [0, 2]) -def test_mock_provider_rejects_missing_or_duplicate_requests(request_count: int) -> None: - response: Final = MockProviderResponse(200, (), b"{}") - with ExitStack() as stack: - api_base: Final = stack.enter_context(mock_provider(response)) - for _ in range(request_count): - with urlopen(Request(api_base, data=b"{}"), timeout=5) as received: - assert received.read() == response.body - with pytest.raises(AssertionError, match=f"expected one provider request, received {request_count}"): - stack.close() diff --git a/tests/sdk_function_trace/test_profiler.py b/tests/sdk_function_trace/test_profiler.py deleted file mode 100644 index 10a266fb1e8..00000000000 --- a/tests/sdk_function_trace/test_profiler.py +++ /dev/null @@ -1,167 +0,0 @@ -from __future__ import annotations - -import asyncio -import sys -from pathlib import Path -from types import FunctionType -from typing import Final, cast - -import pytest - -from tests.sdk_function_trace import ( - FunctionTraceEvent, - TraceScenario, - TraceStep, - assert_function_trace_parity, -) -from tests.sdk_function_trace.profiler import profile_python - - -class First: - @staticmethod - def run() -> None: - return None - - -class Second: - @staticmethod - def run() -> None: - return None - - -def test_profiler_matches_code_objects_and_keeps_repeated_calls() -> None: - with profile_python((First.run,)) as profiler: - Second.run() - First.run() - First.run() - - assert profiler.events == [ - FunctionTraceEvent(function="run", depth=0), - FunctionTraceEvent(function="run", depth=0), - ] - - -def test_profiler_records_selected_function_nesting_depth() -> None: - class Nested: - @staticmethod - def run() -> None: - First.run() - - with profile_python((Nested.run, First.run)) as profiler: - Nested.run() - - assert profiler.events == [ - FunctionTraceEvent(function="run", depth=0), - FunctionTraceEvent(function="run", depth=1), - ] - - -def test_profiler_restores_previous_profiler_after_failure() -> None: - previous: Final = sys.getprofile() - - with profile_python((First.run,)) as outer: - with pytest.raises(RuntimeError, match="stop"): - with profile_python((Second.run,)): - raise RuntimeError("stop") - assert sys.getprofile() is outer - First.run() - - assert sys.getprofile() is previous - assert outer.events == [FunctionTraceEvent(function="run", depth=0)] - - -def test_profiler_does_not_count_coroutine_resumption_as_another_call() -> None: - async def suspended() -> None: - await asyncio.sleep(0) - First.run() - await asyncio.sleep(0) - - with profile_python((suspended, First.run)) as profiler: - asyncio.run(suspended()) - - assert profiler.events == [ - FunctionTraceEvent(function="suspended", depth=0), - FunctionTraceEvent(function="run", depth=1), - ] - - -def test_source_profiler_records_real_frame_ancestry() -> None: - def outer() -> None: - First.run() - - with profile_python(source_root=Path(__file__).parent) as profiler: - outer() - Second.run() - - outer_event, first_event, second_event = ( - event for event in profiler.events if event.function.startswith("test_profiler.py:") - ) - assert first_event.ancestors is not None - assert outer_event.function in first_event.ancestors - assert second_event.ancestors is not None - assert outer_event.function not in second_event.ancestors - - -@pytest.mark.parametrize( - "rust_trace", - [ - (), - (FunctionTraceEvent(function="renamed", depth=0),), - (FunctionTraceEvent(function="run", depth=1),), - (FunctionTraceEvent(function="run", depth=0),) * 2, - ], - ids=["missing", "renamed", "wrong-depth", "extra-call"], -) -def test_harness_rejects_rust_function_trace_drift(rust_trace: tuple[FunctionTraceEvent, ...]) -> None: - with pytest.raises(AssertionError, match="Rust function trace differs"): - assert_function_trace_parity( - TraceScenario( - steps=(TraceStep(cast(FunctionType, First.run), depth=0),), - invoke_python=First.run, - invoke_rust=lambda: rust_trace, - ) - ) - - -def test_harness_rejects_python_function_trace_drift() -> None: - with pytest.raises(AssertionError, match="Python function trace differs"): - assert_function_trace_parity( - TraceScenario( - steps=(TraceStep(cast(FunctionType, First.run), depth=0),), - invoke_python=Second.run, - invoke_rust=lambda: (FunctionTraceEvent(function="run", depth=0),), - ) - ) - - -def test_harness_accepts_matching_traces() -> None: - assert_function_trace_parity( - TraceScenario( - steps=(TraceStep(cast(FunctionType, First.run), depth=0),), - invoke_python=First.run, - invoke_rust=lambda: (FunctionTraceEvent(function="run", depth=0),), - ) - ) - - -def test_harness_rejects_reordered_calls() -> None: - def begin() -> None: - return None - - def finish() -> None: - return None - - with pytest.raises(AssertionError, match="Rust function trace differs"): - assert_function_trace_parity( - TraceScenario( - steps=( - TraceStep(cast(FunctionType, begin), depth=0), - TraceStep(cast(FunctionType, finish), depth=0), - ), - invoke_python=lambda: (begin(), finish()), - invoke_rust=lambda: ( - FunctionTraceEvent(function="finish", depth=0), - FunctionTraceEvent(function="begin", depth=0), - ), - ) - ) diff --git a/tests/sdk_function_trace/test_runtime.py b/tests/sdk_function_trace/test_runtime.py deleted file mode 100644 index 015cba55083..00000000000 --- a/tests/sdk_function_trace/test_runtime.py +++ /dev/null @@ -1,47 +0,0 @@ -from __future__ import annotations - -from typing import Final - -import pytest - -from tests.sdk_function_trace.runtime import ( - TraceFailed, - TraceSkipped, - attempt_trace, - run_trace, - trace_diff, -) -from tests.sdk_function_trace.steps import pipeline_issues, pipeline_steps - - -def test_sync_messages_records_the_known_python_limitation() -> None: - result: Final = attempt_trace("messages", engine="python", asynchronous=False) - - assert isinstance(result, TraceSkipped) - assert result.reason == "ValueError: anthropic_messages_handler is not implemented for sync calls" - - -def test_unexpected_call_failure_is_not_skipped() -> None: - result: Final = attempt_trace("unknown", engine="python", asynchronous=False) - - assert isinstance(result, TraceFailed) - assert result.reason == "ValueError: Unknown route: unknown" - - -@pytest.mark.parametrize( - ("route", "asynchronous"), - (("chat_completions", False), ("chat_completions", True), ("messages", True), ("ocr", False), ("ocr", True)), -) -def test_compiled_routes_match_python_steps(route: str, asynchronous: bool) -> None: - from litellm.rust_bridge import get_native_bridge - - if get_native_bridge() is None: - pytest.skip("build the native bridge to run executed route parity") - python: Final = pipeline_steps(route, "python", run_trace(route, engine="python", asynchronous=asynchronous)) - rust: Final = pipeline_steps(route, "rust", run_trace(route, engine="rust", asynchronous=asynchronous)) - - assert pipeline_issues(route, "python", python) == () - assert pipeline_issues(route, "rust", rust) == () - assert trace_diff(python, rust).matches - if route != "messages": - assert python == rust diff --git a/tests/sdk_function_trace/test_steps.py b/tests/sdk_function_trace/test_steps.py deleted file mode 100644 index b5432951187..00000000000 --- a/tests/sdk_function_trace/test_steps.py +++ /dev/null @@ -1,244 +0,0 @@ -from __future__ import annotations - -from typing import Final - -import pytest - -from tests.sdk_function_trace.profiler import FunctionTraceEvent -from tests.sdk_function_trace.runtime import trace_diff -from tests.sdk_function_trace.steps import pipeline_issues, pipeline_steps - - -def test_python_ocr_projection_keeps_pipeline_and_drops_noise() -> None: - events: Final = ( - FunctionTraceEvent("utils.py:1747 client..wrapper_async", 0), - FunctionTraceEvent("ocr/main.py:331 aocr", 1), - FunctionTraceEvent("ocr/main.py:70 _prepare_ocr_request", 2), - FunctionTraceEvent("litellm_core_utils/get_llm_provider_logic.py:142 get_llm_provider", 3), - FunctionTraceEvent("utils.py:9303 ProviderConfigManager.get_provider_ocr_config", 3), - FunctionTraceEvent("llms/mistral/ocr/transformation.py:34 MistralOCRConfig.get_supported_ocr_params", 4), - FunctionTraceEvent("llms/mistral/ocr/transformation.py:72 MistralOCRConfig.map_ocr_params", 4), - FunctionTraceEvent("llms/mistral/ocr/transformation.py:34 MistralOCRConfig.get_supported_ocr_params", 5), - FunctionTraceEvent("llms/custom_httpx/llm_http_handler.py:1705 BaseLLMHTTPHandler.async_ocr", 2), - FunctionTraceEvent("llms/mistral/ocr/transformation.py:94 MistralOCRConfig.validate_environment", 4), - FunctionTraceEvent("llms/mistral/ocr/transformation.py:124 MistralOCRConfig.get_complete_url", 4), - FunctionTraceEvent("llms/base_llm/ocr/transformation.py:209 BaseOCRConfig.async_transform_ocr_request", 5), - FunctionTraceEvent("llms/mistral/ocr/transformation.py:149 MistralOCRConfig.transform_ocr_request", 6), - FunctionTraceEvent("llms/custom_httpx/http_handler.py:654 AsyncHTTPHandler.post", 6), - FunctionTraceEvent("llms/base_llm/ocr/transformation.py:255 BaseOCRConfig.async_transform_ocr_response", 4), - FunctionTraceEvent("llms/mistral/ocr/transformation.py:200 MistralOCRConfig.transform_ocr_response", 5), - FunctionTraceEvent("cost_calculator.py:1874 ocr_cost", 6), - ) - - assert pipeline_steps("ocr", "python", events) == ( - FunctionTraceEvent("ocr", 0), - FunctionTraceEvent("prepare_ocr_call", 1), - FunctionTraceEvent("get_provider_ocr_config", 2), - FunctionTraceEvent("supported_ocr_params", 3), - FunctionTraceEvent("map_ocr_params", 3), - FunctionTraceEvent("execute_ocr_provider_call", 1), - FunctionTraceEvent("validate_environment", 2), - FunctionTraceEvent("complete_url", 2), - FunctionTraceEvent("transform_ocr_request", 3), - FunctionTraceEvent("http_request", 3), - FunctionTraceEvent("transform_ocr_response", 2), - ) - - -def test_rust_ocr_projection_reuses_step_names_and_keeps_unknown_spans() -> None: - events: Final = ( - FunctionTraceEvent("ocr", 0), - FunctionTraceEvent("prepare_ocr_call", 1), - FunctionTraceEvent("map_ocr_params", 2), - FunctionTraceEvent("supported_ocr_params", 3), - FunctionTraceEvent("map_ocr_params", 2), - FunctionTraceEvent("transform_ocr_request", 2), - FunctionTraceEvent("execute_ocr_provider_call", 2), - FunctionTraceEvent("transform_ocr_response", 3), - FunctionTraceEvent("new_uninstrumented_span", 3), - ) - - assert pipeline_steps("ocr", "rust", events) == ( - FunctionTraceEvent("ocr", 0), - FunctionTraceEvent("prepare_ocr_call", 1), - FunctionTraceEvent("map_ocr_params", 2), - FunctionTraceEvent("supported_ocr_params", 3), - FunctionTraceEvent("transform_ocr_request", 2), - FunctionTraceEvent("execute_ocr_provider_call", 2), - FunctionTraceEvent("transform_ocr_response", 3), - FunctionTraceEvent("new_uninstrumented_span", 3), - ) - - -def test_projection_resets_depth_on_thread_root() -> None: - events: Final = ( - FunctionTraceEvent("main.py:387 acompletion", 1), - FunctionTraceEvent("llms/anthropic/chat/handler.py:255 AnthropicChatCompletion.acompletion_function", 2), - FunctionTraceEvent( - "llms/anthropic/experimental_pass_through/messages/handler.py:416 anthropic_messages_handler", 0 - ), - FunctionTraceEvent( - "llms/anthropic/experimental_pass_through/messages/transformation.py:575" - " AnthropicMessagesConfig.transform_anthropic_messages_request", - 4, - ), - ) - - assert pipeline_steps("chat_completions", "python", events) == ( - FunctionTraceEvent("chat_completions", 0), - FunctionTraceEvent("execute_chat_completions_provider_call", 1), - ) - assert pipeline_steps("messages", "python", events) == ( - FunctionTraceEvent("execute_messages_provider_call", 0), - FunctionTraceEvent("transform_request", 1), - ) - - -@pytest.mark.parametrize("function", ("completion", "completion_function", "acompletion_function")) -def test_chat_projection_includes_sync_and_async_handlers(function: str) -> None: - events: Final = (FunctionTraceEvent(f"llms/anthropic/chat/handler.py:100 AnthropicChatCompletion.{function}", 0),) - - assert pipeline_steps("chat_completions", "python", events) == ( - FunctionTraceEvent("execute_chat_completions_provider_call", 0), - ) - - -def test_trace_diff_reports_no_difference_for_identical_steps() -> None: - steps: Final = ( - FunctionTraceEvent("ocr", 0), - FunctionTraceEvent("transform_ocr_request", 1), - ) - - diff: Final = trace_diff(steps, steps) - - assert diff.python_only == () - assert diff.rust_only == () - assert diff.shared_order_matches - assert diff.matches - - -def test_trace_diff_reports_exclusive_steps_and_reordered_shared_steps() -> None: - python: Final = ( - FunctionTraceEvent("ocr", 0), - FunctionTraceEvent("supported_ocr_params", 1), - FunctionTraceEvent("map_ocr_params", 1), - FunctionTraceEvent("http_request", 2), - ) - rust: Final = ( - FunctionTraceEvent("ocr", 0), - FunctionTraceEvent("map_ocr_params", 1), - FunctionTraceEvent("supported_ocr_params", 2), - FunctionTraceEvent("transform_ocr_response", 2), - ) - - diff: Final = trace_diff(python, rust) - - assert diff.python_only == ("http_request",) - assert diff.rust_only == ("transform_ocr_response",) - assert not diff.shared_order_matches - assert not diff.matches - - -def test_trace_diff_does_not_claim_empty_or_disjoint_traces_match() -> None: - assert not trace_diff((), ()).shared_order_matches - assert not trace_diff((FunctionTraceEvent("ocr", 0),), (FunctionTraceEvent("messages", 0),)).shared_order_matches - - -def test_projection_uses_actual_ancestors_after_coroutine_resumption() -> None: - entrypoint: Final = "main.py:387 acompletion" - handler: Final = "llms/anthropic/chat/handler.py:255 AnthropicChatCompletion.acompletion_function" - events: Final = ( - FunctionTraceEvent(entrypoint, 0, ()), - FunctionTraceEvent(handler, 1, (entrypoint,)), - FunctionTraceEvent("utils.py:100 unrelated_worker", 0, ()), - FunctionTraceEvent("llms/anthropic/chat/transformation.py:100 transform_response", 1, (handler,)), - ) - - assert pipeline_steps("chat_completions", "python", events) == ( - FunctionTraceEvent("chat_completions", 0), - FunctionTraceEvent("execute_chat_completions_provider_call", 1), - FunctionTraceEvent("transform_response", 2), - ) - - -def test_projection_does_not_nest_siblings_under_a_returned_config_lookup() -> None: - events: Final = ( - FunctionTraceEvent("main.py:387 completion", 0), - FunctionTraceEvent("utils.py:100 ProviderConfigManager.get_provider_chat_config", 1), - FunctionTraceEvent("utils.py:200 unrelated_helper", 1), - FunctionTraceEvent("llms/anthropic/chat/transformation.py:100 transform_request", 2), - ) - - assert pipeline_steps("chat_completions", "python", events) == ( - FunctionTraceEvent("chat_completions", 0), - FunctionTraceEvent("get_provider_chat_config", 1), - FunctionTraceEvent("transform_request", 1), - ) - - -CHAT_RUST_STEPS: Final = ( - "chat_completions", - "get_provider_chat_config", - "supported_openai_params", - "execute_chat_completions_provider_call", - "validate_environment", - "transform_request", - "http_request", - "transform_response", -) - - -@pytest.mark.parametrize("missing", CHAT_RUST_STEPS) -def test_pipeline_check_rejects_missing_stages(missing: str) -> None: - steps: Final = tuple(FunctionTraceEvent(name, 0) for name in CHAT_RUST_STEPS if name != missing) - - assert f"missing {missing}" in pipeline_issues("chat_completions", "rust", steps) - - -def test_pipeline_check_rejects_http_before_request_transformation() -> None: - steps: Final = tuple( - FunctionTraceEvent(name, 0) - for name in ( - "chat_completions", - "get_provider_chat_config", - "supported_openai_params", - "execute_chat_completions_provider_call", - "validate_environment", - "http_request", - "transform_request", - "transform_response", - ) - ) - - assert "transform_request must precede http_request" in pipeline_issues("chat_completions", "rust", steps) - - -def test_step_parity_rejects_different_handler_boundaries_even_with_valid_stages() -> None: - rust: Final = tuple(FunctionTraceEvent(name, 0) for name in CHAT_RUST_STEPS) - python: Final = tuple( - FunctionTraceEvent(name, 0) - for name in ( - "chat_completions", - "get_provider_chat_config", - "supported_openai_params", - "validate_environment", - "transform_request", - "execute_chat_completions_provider_call", - "http_request", - "transform_response", - ) - ) - - assert not trace_diff(python, rust).shared_order_matches - assert not trace_diff(python, rust).matches - assert pipeline_issues("chat_completions", "python", python) == () - assert pipeline_issues("chat_completions", "rust", rust) == () - - -def test_step_parity_rejects_an_exclusive_helper_with_matching_shared_order() -> None: - rust: Final = tuple(FunctionTraceEvent(name, 0) for name in CHAT_RUST_STEPS) - python: Final = (*rust, FunctionTraceEvent("unmatched_helper", 0)) - diff: Final = trace_diff(python, rust) - - assert diff.shared_order_matches - assert not diff.matches diff --git a/tests/sdk_function_trace/test_table.py b/tests/sdk_function_trace/test_table.py deleted file mode 100644 index c2341a391a9..00000000000 --- a/tests/sdk_function_trace/test_table.py +++ /dev/null @@ -1,67 +0,0 @@ -from __future__ import annotations - -import re -from typing import Final - -from tests.sdk_function_trace.profiler import FunctionTraceEvent -from tests.sdk_function_trace.table import format_trace_table - - -def test_table_aligns_matches_after_missing_steps_and_preserves_indentation() -> None: - python: Final = ( - FunctionTraceEvent("ocr", 0), - FunctionTraceEvent("python_helper", 1), - FunctionTraceEvent("http_request", 2), - ) - rust: Final = ( - FunctionTraceEvent("ocr", 0), - FunctionTraceEvent("rust_helper", 1), - FunctionTraceEvent("http_request", 1), - ) - output: Final = format_trace_table(python, rust, colorize=False) - rows: Final = tuple(line.split("|")[1:-1] for line in output.splitlines() if line.startswith("|")) - - assert tuple(tuple(cell.strip() for cell in row) for row in rows) == ( - ("python (3 steps)", "rust (3 steps)", "comparison"), - ("ocr", "ocr", "match"), - ("python_helper", "", "python only"), - ("", "rust_helper", "rust only"), - ("http_request", "http_request", "match"), - ) - assert rows[-1][0].startswith(" http_request") - assert rows[-1][1].startswith(" http_request") - assert len({len(line) for line in output.splitlines()}) == 1 - assert "\033[" not in output - - -def test_table_marks_reordered_calls_and_keeps_both_execution_orders() -> None: - python: Final = tuple(FunctionTraceEvent(name, 0) for name in ("ocr", "map", "validate", "http")) - rust: Final = tuple(FunctionTraceEvent(name, 0) for name in ("ocr", "validate", "map", "http")) - output: Final = format_trace_table(python, rust, colorize=True) - plain: Final = re.sub(r"\033\[[0-9;]*m", "", output) - rows: Final = tuple(line.split("|")[1:-1] for line in plain.splitlines() if line.startswith("|"))[1:] - - assert tuple(row[0].strip() for row in rows if row[0].strip()) == tuple(event.function for event in python) - assert tuple(row[1].strip() for row in rows if row[1].strip()) == tuple(event.function for event in rust) - assert plain.count("reordered") == 2 - assert output.count("\033[31m") == 2 - assert "only" not in output - - -def test_table_colors_match_and_exclusive_rows_without_changing_alignment() -> None: - python: Final = (FunctionTraceEvent("ocr", 0), FunctionTraceEvent("python_helper", 1)) - rust: Final = (FunctionTraceEvent("ocr", 0), FunctionTraceEvent("rust_helper", 1)) - colored: Final = format_trace_table(python, rust, colorize=True) - - assert re.sub(r"\033\[[0-9;]*m", "", colored) == format_trace_table(python, rust, colorize=False) - assert next(line for line in colored.splitlines() if "match" in line).startswith("\033[32m") - assert next(line for line in colored.splitlines() if "python only" in line).startswith("\033[34m") - assert next(line for line in colored.splitlines() if "rust only" in line).startswith("\033[33m") - - -def test_table_handles_empty_traces() -> None: - output: Final = format_trace_table((), (), colorize=False) - - assert "python (0 steps)" in output - assert "rust (0 steps)" in output - assert "match" not in output diff --git a/tests/test_litellm/ocr/test_rust_bridge.py b/tests/test_litellm/ocr/test_rust_bridge.py index 0764aec7185..4afb8303d03 100644 --- a/tests/test_litellm/ocr/test_rust_bridge.py +++ b/tests/test_litellm/ocr/test_rust_bridge.py @@ -297,6 +297,25 @@ def test_native_bridge_loader_caches_absent_extension(monkeypatch): assert attempts == 1 +def test_native_bridge_loader_reset_forces_relookup(monkeypatch): + real_import = builtins.__import__ + attempts = 0 + + def fake_import(name, globals=None, locals=None, fromlist=(), level=0): + nonlocal attempts + if name == "litellm.rust_bridge" and "_native" in fromlist: + attempts += 1 + raise ImportError + return real_import(name, globals, locals, fromlist, level) + + monkeypatch.setattr(builtins, "__import__", fake_import) + + assert rust_bridge_loader.get_native_bridge() is None + rust_bridge_loader.reset_native_bridge_cache() + assert rust_bridge_loader.get_native_bridge() is None + assert attempts == 2 + + def test_native_bridge_available_reflects_loader(monkeypatch): fake_module = types.ModuleType("litellm.rust_bridge._native") monkeypatch.setattr(rust_bridge_loader, "get_native_bridge", lambda: fake_module) diff --git a/tests/test_litellm/rust_bridge/native_route_wheel_test.py b/tests/test_litellm/rust_bridge/native_route_wheel_test.py index a89a985952b..a7f50a82a99 100644 --- a/tests/test_litellm/rust_bridge/native_route_wheel_test.py +++ b/tests/test_litellm/rust_bridge/native_route_wheel_test.py @@ -181,14 +181,6 @@ def assert_success(route: str, response: object) -> None: raise AssertionError(f"{route} returned {actual!r}, expected {expected!r}") -def assert_traced_success(route: str, response: object) -> None: - if not isinstance(response, dict): - raise TypeError(f"{route} returned {type(response).__name__}, expected a traced dict") - assert_success(route, response["response"]) - expected_function: Final = "audio_transcription" if route == "transcription" else route - assert response["trace"][0] == {"function": expected_function, "depth": 0} - - def success_value(route: str, response: dict[object, object]) -> object: if route == "ocr": return response["pages"][0]["markdown"] @@ -213,7 +205,6 @@ def exercise_sync(native: object, api_base: str) -> None: for route in ("ocr", "transcription", "messages", "chat_completions"): function: Final = getattr(native, route) assert_success(route, function(**route_kwargs(route, api_base, "success"))) - assert_traced_success(route, function(**route_kwargs(route, api_base, "success"), trace=True)) try: function(**route_kwargs(route, api_base, "429")) except (RuntimeError, native.RustUpstreamError) as error: @@ -226,7 +217,6 @@ async def exercise_async(native: object, api_base: str) -> None: for route in ("ocr", "transcription", "messages", "chat_completions"): function: Final = getattr(native, f"a{route}") assert_success(route, await function(**route_kwargs(route, api_base, "success"))) - assert_traced_success(route, await function(**route_kwargs(route, api_base, "success"), trace=True)) try: await function(**route_kwargs(route, api_base, "429")) except (RuntimeError, native.RustUpstreamError) as error: @@ -251,6 +241,8 @@ async def exercise_async_concurrency(native: object, api_base: str) -> None: def exercise_routes(native_path: Path, api_base: str) -> object: native: Final = load_native(native_path) + if hasattr(native, "_trace"): + raise AssertionError("release wheel exposed trace-parity diagnostics") exercise_sync(native, api_base) asyncio.run(exercise_async(native, api_base)) asyncio.run(exercise_async_concurrency(native, api_base)) diff --git a/tests/test_rust_python_harness.py b/tests/test_rust_python_harness.py index a2b9c8e2a76..72860c427d4 100644 --- a/tests/test_rust_python_harness.py +++ b/tests/test_rust_python_harness.py @@ -1,206 +1,86 @@ from __future__ import annotations import importlib -import json -import os -import subprocess -import sys from pathlib import Path from typing import Final import pytest -catalog = importlib.import_module("tests.rust-python-harness.catalog") -cli = importlib.import_module("tests.rust-python-harness.cli") models = importlib.import_module("tests.rust-python-harness.shared.reporting.models") -runner = importlib.import_module("tests.rust-python-harness.shared.reporting.pytest_runner") +strategy_module = importlib.import_module("tests.rust-python-harness.shared.reporting.strategy") ui = importlib.import_module("tests.rust-python-harness.shared.reporting.ui") -ledger_module = importlib.import_module("tests.rust-python-harness.shared.parity.ledger") -mapping_validator = importlib.import_module( - "tests.rust-python-harness.strategies.unit_tests.mapping_validator" -) +mapping_validator = importlib.import_module("tests.rust-python-harness.strategies.unit_tests_mapping.mapping_validator") +mappings = importlib.import_module("tests.rust-python-harness.strategies.unit_tests_mapping.mappings") +ocr_mapping = importlib.import_module("tests.rust-python-harness.strategies.unit_tests_mapping.cases.ocr") +cli = importlib.import_module("tests.rust-python-harness.cli") -load_catalog = catalog.load_catalog -load_ledger = ledger_module.load_ledger -ledger_path_for = mapping_validator.ledger_path_for -REPO_ROOT = mapping_validator.REPO_ROOT -audit_ledger = mapping_validator.audit_ledger -build_function_report = mapping_validator.build_function_report -_pick_values = cli._pick_values -_coverage_pytest_args = cli._coverage_pytest_args -_select = cli._select -_validate_ledger = cli._validate_ledger +audit_mapping = mapping_validator.audit_mapping +UNIT_TEST_CONTRACTS = mappings.UNIT_TEST_CONTRACTS +OCR_CONTRACT = ocr_mapping.OCR_CONTRACT +REPO_ROOT = Path(__file__).resolve().parents[1] CaseResult = models.CaseResult Coverage = models.Coverage HarnessCase = models.HarnessCase HarnessRun = models.HarnessRun RunStatus = models.RunStatus -SDK_FUNCTIONS = models.SDK_FUNCTIONS -section_confidence = models.section_confidence -run_pytest = runner.run_pytest -runnable_selectors = runner.runnable_selectors -selector_matches_node = runner.selector_matches_node +ModuleCaseSpec = strategy_module.ModuleCaseSpec +NotImplementedCaseSpec = strategy_module.NotImplementedCaseSpec +SkippedCaseSpec = strategy_module.SkippedCaseSpec _format_duration = ui._format_duration -_rerun_command = ui._rerun_command _summary = ui._summary -def _case( - *, selectors: tuple[str, ...] = (), coverage: Coverage = Coverage.COMPLETE -) -> HarnessCase: +def _case(module: str = "tests.example") -> HarnessCase: return HarnessCase( strategy_id="example", strategy_label="Example", sdk_function="messages", - coverage=coverage, - selectors=selectors, + spec=ModuleCaseSpec(coverage=Coverage.COMPLETE, module=module), ) -def _manifest() -> dict[str, object]: - return { - "order": 1, - "id": "example", - "label": "Example strategy", - "description": "Example description", - "functions": { - function: {"coverage": "planned", "selectors": []} - for function in SDK_FUNCTIONS - }, - } - - -def test_should_load_the_four_harness_strategies_in_order() -> None: - strategies = load_catalog() - - assert [strategy.id for strategy in strategies] == [ - "e2e_parity", - "trace_parity", - "unit_tests", - "existing_e2e_test_sdk", - ] - assert all( - tuple(case.sdk_function for case in strategy.cases) == SDK_FUNCTIONS - for strategy in strategies - ) - - -def test_should_reject_a_manifest_missing_an_sdk_function(tmp_path: Path) -> None: - strategy_directory = tmp_path / "example" - strategy_directory.mkdir() - manifest = _manifest() - del manifest["functions"]["count_tokens"] # type: ignore[index] - (strategy_directory / "strategy.json").write_text( - json.dumps(manifest), encoding="utf-8" - ) - - with pytest.raises(ValueError, match="functions must exactly match"): - load_catalog(tmp_path) - - @pytest.mark.parametrize( - ("selector", "nodeid", "matches"), + "module", [ - ("tests/test_parity.py", "tests/test_parity.py::test_one", True), - ("tests/test_parity.py::test_one", "tests/test_parity.py::test_one", True), - ( - "tests/test_parity.py::test_one", - "tests/test_parity.py::test_one[value]", - True, - ), - ("tests/test_parity.py::test_one", "tests/test_parity.py::test_two", False), - ("tests/ocr_tests/", "tests/ocr_tests/test_ocr_mistral.py::test_one", True), - ("tests/ocr_tests/", "tests/other_tests/test_ocr_mistral.py::test_one", False), + "tests.rust-python-harness.strategies.e2e_parity.sdk.ocr.test_sdk_parity", + "tests.rust-python-harness.strategies.trace_parity.sdk.ocr.case", + "tests.rust-python-harness.strategies.trace_parity.sdk.messages.case", + "tests.rust-python-harness.strategies.trace_parity.sdk.chat_completions.case", + "tests.rust-python-harness.strategies.trace_parity.sdk.transcription.case", + "tests.rust-python-harness.strategies.trace_parity.gateway.messages.case", ], ) -def test_should_match_pytest_file_and_node_selectors( - selector: str, nodeid: str, matches: bool -) -> None: - assert selector_matches_node(selector, nodeid) is matches +def test_implemented_namespace_case_modules_remain_importable(module: str) -> None: + assert importlib.import_module(module) -def test_should_only_return_selectors_whose_files_exist(tmp_path: Path) -> None: - existing = tmp_path / "tests" / "test_parity.py" - existing.parent.mkdir() - existing.write_text("", encoding="utf-8") - case = _case( - selectors=("tests/test_parity.py", "tests/test_missing.py::test_missing") +def test_should_mark_not_implemented_and_skipped_cases_without_running() -> None: + not_implemented: Final = CaseResult( + case=HarnessCase( + strategy_id="example", + strategy_label="Example", + sdk_function="messages", + spec=NotImplementedCaseSpec(reason="No case is registered."), + ) + ) + skipped: Final = CaseResult( + case=HarnessCase( + strategy_id="example", + strategy_label="Example", + sdk_function="messages", + spec=SkippedCaseSpec(reason="The surface does not apply."), + ) ) - assert runnable_selectors((case,), tmp_path) == ("tests/test_parity.py",) + not_implemented.set_initial_status() + skipped.set_initial_status() - -def test_should_treat_an_existing_folder_selector_as_runnable(tmp_path: Path) -> None: - (tmp_path / "tests" / "ocr_tests").mkdir(parents=True) - case = _case(selectors=("tests/ocr_tests/",)) - - assert runnable_selectors((case,), tmp_path) == ("tests/ocr_tests/",) - - -def test_should_mark_planned_and_not_applicable_cases_without_running() -> None: - planned = CaseResult(case=_case(coverage=Coverage.PLANNED)) - not_applicable = CaseResult(case=_case(coverage=Coverage.NOT_APPLICABLE)) - - planned.set_initial_status() - not_applicable.set_initial_status() - - assert planned.status is RunStatus.PLANNED - assert not_applicable.status is RunStatus.NOT_APPLICABLE - - -def test_should_treat_an_all_planned_filtered_run_as_success(tmp_path: Path) -> None: - exit_code, run = run_pytest( - cases=(_case(coverage=Coverage.PLANNED),), - repo_root=tmp_path, - on_update=lambda _: None, - ) - - assert exit_code == 0 - assert next(iter(run.results.values())).status is RunStatus.PLANNED - - -@pytest.mark.parametrize("strategy_id", ("e2e_parity", "existing_e2e_test_sdk")) -def test_should_run_namespace_package_relative_imports(tmp_path: Path, strategy_id: str) -> None: - package: Final = tmp_path / "manual_suite" / "relative-tests" - package.mkdir(parents=True) - (package / "__init__.py").write_text("", encoding="utf-8") - (package / "values.py").write_text("ANSWER = 42\n", encoding="utf-8") - (package / "test_relative.py").write_text( - "from .values import ANSWER\n\ndef test_answer():\n assert ANSWER == 42\n", - encoding="utf-8", - ) - result: Final = subprocess.run( - ( - sys.executable, - "-c", - "import importlib\n" - "from pathlib import Path\n" - "cli = importlib.import_module('tests.rust-python-harness.cli')\n" - "models = importlib.import_module('tests.rust-python-harness.shared.reporting.models')\n" - f"case = models.HarnessCase(strategy_id={strategy_id!r}, strategy_label='Example', " - "sdk_function='ocr', coverage=models.Coverage.COMPLETE, " - "selectors=('manual_suite/relative-tests/',))\n" - f"code, run = cli._resolve_runner({strategy_id!r})((case,), Path.cwd(), lambda _: None)\n" - "assert code == 0, code\n" - "assert next(iter(run.results.values())).passed == 1\n", - ), - cwd=tmp_path, - env={ - **os.environ, - "PYTHONPATH": os.pathsep.join((str(tmp_path), str(Path(__file__).resolve().parents[1]))), - "PYTEST_DISABLE_PLUGIN_AUTOLOAD": "1", - }, - capture_output=True, - text=True, - timeout=30, - check=False, - ) - - assert result.returncode == 0, result.stdout + result.stderr + assert not_implemented.status is RunStatus.NOT_IMPLEMENTED + assert skipped.status is RunStatus.SKIPPED def test_should_finalize_a_fully_passing_case() -> None: - result = CaseResult(case=_case(selectors=("tests/test_parity.py",))) + result = CaseResult(case=_case()) result.set_initial_status() result.collected.update({"one", "two"}) result.completed.update({"one", "two"}) @@ -212,7 +92,7 @@ def test_should_finalize_a_fully_passing_case() -> None: def test_should_replace_a_pass_with_a_teardown_error() -> None: - result = CaseResult(case=_case(selectors=("tests/test_parity.py",))) + result = CaseResult(case=_case()) result.set_initial_status() result.collected.add("one") @@ -225,136 +105,37 @@ def test_should_replace_a_pass_with_a_teardown_error() -> None: assert result.duration == pytest.approx(0.3) -def test_should_filter_the_catalog_by_strategy_and_sdk_function() -> None: - strategies = load_catalog() - - cases = _select(strategies, {"e2e_parity"}, {"messages"}) - - assert len(cases) == 1 - assert cases[0].key == "e2e_parity:messages" - - -def test_should_reject_an_unknown_strategy() -> None: - with pytest.raises(ValueError, match="Unknown strategy"): - _select(load_catalog(), {"not-real"}, set()) - - -def test_should_pick_multiple_interactive_filters() -> None: - answers = iter(["nope", "1, 3"]) - - selected = _pick_values( - "Examples", - (("one", "One"), ("two", "Two"), ("three", "Three")), - input_fn=lambda _: next(answers), - ) - - assert selected == {"one", "three"} - - def test_should_format_developer_facing_run_context() -> None: - run = HarnessRun.from_cases((_case(selectors=("tests/test_parity.py",)),)) + run = HarnessRun.from_cases((_case(),)) result = next(iter(run.results.values())) result.collected.add("tests/test_parity.py::test_one") result.record("tests/test_parity.py::test_one", RunStatus.PASSED, 1.25) assert _summary(run) == (1, 0, 0, 0) assert _format_duration(1.25) == "1.2s" - assert _rerun_command("tests/test_parity.py::test_one") == ( - "poetry run pytest tests/test_parity.py::test_one -q -o consider_namespace_packages=true" - ) - assert _rerun_command("tests/test_parity.py::test_one[value with spaces]") == ( - "poetry run pytest 'tests/test_parity.py::test_one[value with spaces]' -q -o consider_namespace_packages=true" + + +def test_should_leave_functions_without_mapping_contracts_unimplemented() -> None: + assert "messages" not in UNIT_TEST_CONTRACTS + + +def test_should_derive_ocr_mapping_status_from_live_tests() -> None: + report = audit_mapping(OCR_CONTRACT, repo_root=REPO_ROOT) + + assert report.is_valid, ( + f"Missing Python tests: {list(report.missing_python_tests)}\n" + f"Missing Rust tests: {list(report.missing_rust_tests)}\n" + f"Duplicate Python mappings: {list(report.duplicate_python_mappings)}\n" + f"Invalid parity exclusions: {list(report.invalid_unit_parity_exclusions)}" ) + assert report.mapped_count == len(OCR_CONTRACT.mapping.mappings) + assert report.total_count == report.mapped_count + len(report.unmapped_python_tests) -def test_should_build_python_coverage_reports_below_the_target_directory( - tmp_path: Path, -) -> None: - args = _coverage_pytest_args(tmp_path) - - assert tmp_path.is_dir() - assert "--cov=litellm" in args - assert "--cov-context=test" in args - assert f"--cov-report=json:{tmp_path / 'python.json'}" in args - assert f"--cov-report=xml:{tmp_path / 'python.xml'}" in args - assert f"--cov-report=html:{tmp_path / 'python-html'}" in args - - -def test_should_report_confidence_for_each_sdk_section() -> None: - strategies = load_catalog() - cases = tuple(case for strategy in strategies for case in strategy.cases) - run = HarnessRun.from_cases(cases) - passing = run.results["e2e_parity:responses"] - passing.collected.add("tests/test_parity.py::test_one") - passing.record("tests/test_parity.py::test_one", RunStatus.PASSED) - - scores = { - score.sdk_function: score for score in section_confidence(run, strategies) - } - - assert scores["responses"].verified_strategies == 1 - assert scores["responses"].required_strategies == 4 - assert scores["responses"].percentage == 25 - assert scores["responses"].level.value == "MEDIUM" - assert scores["count_tokens"].percentage == 0 - assert scores["count_tokens"].level.value == "LOW" - - - -def test_should_report_no_ledger_for_a_function_without_one() -> None: - report = build_function_report("messages", repo_root=REPO_ROOT) - - assert report.has_ledger is False - assert report.is_clean is True - - -def test_should_report_ocr_ledger_stats_and_a_clean_audit() -> None: - ledger = load_ledger(ledger_path_for("ocr")) - - report = build_function_report("ocr", repo_root=REPO_ROOT) - - assert report.has_ledger is True - assert report.ledger.mapped_count == ledger.mapped_count - assert report.ledger.total_count == ledger.total_count - assert report.is_clean is True - - -def test_should_scope_validate_ledger_to_the_requested_function( - capsys: pytest.CaptureFixture[str], -) -> None: - exit_code = _validate_ledger({"messages"}) - - captured = capsys.readouterr() - assert exit_code == 0 - assert "messages" in captured.out - assert "no ledger yet" in captured.out - assert "ocr" not in captured.out - - -@pytest.mark.parametrize("strategy_id", (None, "e2e_parity", "trace_parity", "unit_tests", "existing_e2e_test_sdk")) -def test_should_validate_chat_completions_ledger_from_each_runner( - strategy_id: str | None, capsys: pytest.CaptureFixture[str] -) -> None: - exit_code: Final = cli.main( - ("--validate-ledger", "--function", "chat_completions"), strategy_id=strategy_id - ) +def test_strategy_subcommand_accepts_function_filter(capsys: pytest.CaptureFixture[str]) -> None: + exit_code: Final = cli.main(["run", "unit_tests_mapping", "--function", "messages"]) captured: Final = capsys.readouterr() assert exit_code == 0 - assert "chat_completions" in captured.out - assert "no ledger yet" in captured.out - assert "ocr" not in captured.out - - -def test_should_have_every_python_and_rust_ocr_test_accounted_for_in_the_ledger() -> None: - ledger = load_ledger(ledger_path_for("ocr")) - - report = audit_ledger(ledger, repo_root=REPO_ROOT) - - assert report.is_clean, ( - "\nOCR test-parity ledger is out of sync with the live test files.\n" - f"Ledger references a Python test that no longer exists: {list(report.missing_python_tests)}\n" - f"Python test exists but is not tracked in the ledger: {list(report.stale_python_tests)}\n" - f"Ledger references a Rust test that no longer exists: {list(report.missing_rust_tests)}\n" - f"Rust test exists but is not tracked in the ledger: {list(report.stale_rust_tests)}\n" - ) + assert "- messages: not_implemented" in captured.out + assert "unit_tests_mapping:messages: not_implemented" not in captured.out