diff --git a/litellm-rust/Cargo.lock b/litellm-rust/Cargo.lock index b2f788beb37..603047db7dc 100644 --- a/litellm-rust/Cargo.lock +++ b/litellm-rust/Cargo.lock @@ -1428,7 +1428,9 @@ dependencies = [ "aws-sigv4", "aws-smithy-runtime-api", "aws-types", + "bytes", "futures-util", + "mime", "rand 0.8.7", "reqwest", "serde", @@ -1436,6 +1438,7 @@ dependencies = [ "sha2 0.10.9", "thiserror 2.0.19", "tokio", + "url", ] [[package]] @@ -2720,6 +2723,7 @@ dependencies = [ "idna", "percent-encoding", "serde", + "serde_derive", ] [[package]] diff --git a/litellm-rust/Cargo.toml b/litellm-rust/Cargo.toml index f99d7b47918..69728a652bd 100644 --- a/litellm-rust/Cargo.toml +++ b/litellm-rust/Cargo.toml @@ -33,6 +33,9 @@ tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "net"] tokio-tungstenite = { version = "0.24", default-features = false, features = ["connect", "rustls-tls-native-roots"] } futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] } base64 = "0.22" +bytes = "1" +mime = "0.3" +url = { version = "2", features = ["serde"] } [profile.release] opt-level = 3 diff --git a/litellm-rust/crates/core/Cargo.toml b/litellm-rust/crates/core/Cargo.toml index 2b7c76a9bec..46e22bc866a 100644 --- a/litellm-rust/crates/core/Cargo.toml +++ b/litellm-rust/crates/core/Cargo.toml @@ -7,11 +7,14 @@ repository.workspace = true [dependencies] futures-util.workspace = true +bytes.workspace = true +mime.workspace = true rand.workspace = true reqwest.workspace = true serde.workspace = true serde_json.workspace = true thiserror.workspace = true +url.workspace = 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 } diff --git a/litellm-rust/crates/core/src/ocr/canonical.rs b/litellm-rust/crates/core/src/ocr/canonical.rs new file mode 100644 index 00000000000..8d77e5509a9 --- /dev/null +++ b/litellm-rust/crates/core/src/ocr/canonical.rs @@ -0,0 +1,234 @@ +use std::collections::BTreeMap; + +use bytes::Bytes; +use mime::Mime; +use serde_json::Value; +use thiserror::Error; +use url::Url; + +use super::policy::OcrCanonicalField; +use super::types::{Field, OcrDialectId}; + +#[derive(Clone, Debug, Error, PartialEq, Eq)] +pub enum CanonicalOcrError { + #[error("invalid OCR parameter {field}: {reason}")] + InvalidParameter { + field: &'static str, + reason: &'static str, + }, + #[error("provider extra collides with canonical OCR field: {0}")] + ExtraCollidesWithCanonical(String), + #[error("LiteLLM control cannot enter OCR provider extras: {0}")] + ReservedExtra(String), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DocumentKind { + Image, + Pdf, +} + +#[derive(Clone, PartialEq)] +pub enum OcrDocument { + RemoteUrl { + kind: DocumentKind, + url: Url, + }, + Inline { + kind: DocumentKind, + media_type: Mime, + bytes: Bytes, + }, + ProviderReference { + provider: OcrDialectId, + id: String, + }, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PageSelection(Vec); + +impl PageSelection { + pub fn new(pages: impl IntoIterator) -> Self { + Self(pages.into_iter().collect()) + } + + pub fn pages(&self) -> &[u32] { + &self.0 + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct AnnotationFormat(Value); + +impl AnnotationFormat { + pub fn new(schema: Value) -> Result { + if !schema.is_object() { + return Err(CanonicalOcrError::InvalidParameter { + field: "annotation_format", + reason: "must be a JSON object", + }); + } + Ok(Self(schema)) + } + + pub fn as_value(&self) -> &Value { + &self.0 + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TableFormat { + Html, + Markdown, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ConfidenceScoresGranularity { + Page, + Word, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct OcrOutputOptions { + pub include_image_base64: Field, + pub image_limit: Field, + pub image_min_size: Field, + pub bbox_annotation_format: Field, + pub document_annotation_format: Field, + pub document_annotation_prompt: Field, + pub extract_header: Field, + pub extract_footer: Field, + pub table_format: Field, + pub confidence_scores_granularity: Field, + pub include_blocks: Field, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct OcrRequestId(String); + +impl OcrRequestId { + pub fn new(value: impl Into) -> Result { + let value = value.into(); + if value.trim().is_empty() { + return Err(CanonicalOcrError::InvalidParameter { + field: "id", + reason: "must not be blank", + }); + } + Ok(Self(value)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct ExplicitProviderExtras { + dialect: OcrDialectId, + fields: BTreeMap, +} + +impl ExplicitProviderExtras { + pub fn try_new( + dialect: OcrDialectId, + fields: BTreeMap, + ) -> Result { + if let Some(field) = fields.keys().find(|field| { + OcrCanonicalField::from_wire_name(field).is_some() || is_litellm_control(field) + }) { + return Err(if OcrCanonicalField::from_wire_name(field).is_some() { + CanonicalOcrError::ExtraCollidesWithCanonical(field.clone()) + } else { + CanonicalOcrError::ReservedExtra(field.clone()) + }); + } + Ok(Self { dialect, fields }) + } + + pub fn dialect(&self) -> OcrDialectId { + self.dialect + } + + pub fn fields(&self) -> &BTreeMap { + &self.fields + } +} + +fn is_litellm_control(field: &str) -> bool { + field.starts_with("litellm_") + || matches!( + field, + "api_base" + | "api_key" + | "custom_llm_provider" + | "fallbacks" + | "metadata" + | "mock_response" + | "num_retries" + | "request_timeout" + | "retry_policy" + ) +} + +#[derive(Clone, PartialEq)] +pub struct CanonicalOcrRequest { + pub model: String, + pub document: OcrDocument, + pub pages: Field, + pub output: OcrOutputOptions, + pub request_id: Field, + pub provider_extras: ExplicitProviderExtras, +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn extras_reject_canonical_field_collisions() { + let error = ExplicitProviderExtras::try_new( + OcrDialectId::Mistral, + BTreeMap::from([("pages".to_string(), json!([0]))]), + ) + .expect_err("canonical fields cannot enter extras"); + assert_eq!( + error, + CanonicalOcrError::ExtraCollidesWithCanonical("pages".to_string()) + ); + } + + #[test] + fn extras_reject_litellm_controls() { + for field in ["request_timeout", "litellm_future_control"] { + let error = ExplicitProviderExtras::try_new( + OcrDialectId::Mistral, + BTreeMap::from([(field.to_string(), json!(true))]), + ) + .expect_err("LiteLLM controls cannot enter extras"); + assert_eq!(error, CanonicalOcrError::ReservedExtra(field.to_string())); + } + } + + #[test] + fn extras_remain_bound_to_one_dialect() { + let extras = ExplicitProviderExtras::try_new( + OcrDialectId::ReductoV3, + BTreeMap::from([("chunking".to_string(), json!({"size": 1}))]), + ) + .expect("provider field is accepted"); + assert_eq!(extras.dialect(), OcrDialectId::ReductoV3); + assert_eq!(extras.fields()["chunking"], json!({"size": 1})); + } + + #[test] + fn canonical_fields_preserve_absent_null_and_value() { + assert_ne!(Field::::Absent, Field::Null); + assert_ne!( + Field::Null, + Field::Value(PageSelection::new([0_u32, 2_u32])) + ); + } +} diff --git a/litellm-rust/crates/core/src/ocr/mod.rs b/litellm-rust/crates/core/src/ocr/mod.rs index ec2fbb969a6..d28291a971e 100644 --- a/litellm-rust/crates/core/src/ocr/mod.rs +++ b/litellm-rust/crates/core/src/ocr/mod.rs @@ -1,2 +1,5 @@ +pub mod canonical; +pub mod policy; +pub mod response; pub mod transformation; pub mod types; diff --git a/litellm-rust/crates/core/src/ocr/policy.rs b/litellm-rust/crates/core/src/ocr/policy.rs new file mode 100644 index 00000000000..ae0d68157b6 --- /dev/null +++ b/litellm-rust/crates/core/src/ocr/policy.rs @@ -0,0 +1,106 @@ +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ParameterDisposition { + Forward, + Rename(&'static str), + Transform, + ConsumeAsConfiguration, + Reject, +} + +macro_rules! ocr_parameter_schema { + ($(($variant:ident, $field:ident, $wire_name:literal)),+ $(,)?) => { + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + pub enum OcrCanonicalField { + $($variant),+ + } + + impl OcrCanonicalField { + pub const ALL: [Self; ocr_parameter_schema!(@count $($variant),+)] = [ + $(Self::$variant),+ + ]; + + pub const fn wire_name(self) -> &'static str { + match self { + $(Self::$variant => $wire_name),+ + } + } + + pub fn from_wire_name(value: &str) -> Option { + match value { + $($wire_name => Some(Self::$variant)),+, + _ => None, + } + } + } + + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + pub struct OcrParameterPolicy { + $(pub $field: ParameterDisposition),+ + } + + impl OcrParameterPolicy { + pub const fn disposition(self, field: OcrCanonicalField) -> ParameterDisposition { + match field { + $(OcrCanonicalField::$variant => self.$field),+ + } + } + } + }; + (@count $($item:ident),+) => { + <[()]>::len(&[$(ocr_parameter_schema!(@replace $item ())),+]) + }; + (@replace $_item:ident $sub:expr) => { $sub }; +} + +ocr_parameter_schema!( + (Pages, pages, "pages"), + ( + IncludeImageBase64, + include_image_base64, + "include_image_base64" + ), + (ImageLimit, image_limit, "image_limit"), + (ImageMinSize, image_min_size, "image_min_size"), + ( + BboxAnnotationFormat, + bbox_annotation_format, + "bbox_annotation_format" + ), + ( + DocumentAnnotationFormat, + document_annotation_format, + "document_annotation_format" + ), + ( + DocumentAnnotationPrompt, + document_annotation_prompt, + "document_annotation_prompt" + ), + (ExtractHeader, extract_header, "extract_header"), + (ExtractFooter, extract_footer, "extract_footer"), + (TableFormat, table_format, "table_format"), + ( + ConfidenceScoresGranularity, + confidence_scores_granularity, + "confidence_scores_granularity" + ), + (IncludeBlocks, include_blocks, "include_blocks"), + (RequestId, request_id, "id"), +); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_canonical_field_round_trips_through_its_wire_name() { + assert_eq!(OcrCanonicalField::ALL.len(), 13); + for field in OcrCanonicalField::ALL { + assert_eq!( + OcrCanonicalField::from_wire_name(field.wire_name()), + Some(field) + ); + } + assert_eq!(OcrCanonicalField::from_wire_name("provider_private"), None); + } +} diff --git a/litellm-rust/crates/core/src/ocr/response.rs b/litellm-rust/crates/core/src/ocr/response.rs new file mode 100644 index 00000000000..1338d99ae2d --- /dev/null +++ b/litellm-rust/crates/core/src/ocr/response.rs @@ -0,0 +1,79 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct OcrPage { + pub index: u32, + pub markdown: String, +} + +#[derive(Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct OcrUsage { + pub pages_processed: Option, + pub credits: Option, + pub document_size_bytes: Option, +} + +#[derive(Clone, PartialEq, Serialize, Deserialize)] +pub struct NormalizedOcr { + pub pages: Vec, + pub model: String, + pub document_annotation: Option, + pub content: Option, + pub tables: Option>, + pub key_value_pairs: Option>, + pub usage: OcrUsage, +} + +#[derive(Clone, PartialEq)] +pub struct NativeOcrPayload(Value); + +impl NativeOcrPayload { + pub fn new(value: Value) -> Self { + Self(value) + } + + pub fn as_value(&self) -> &Value { + &self.0 + } + + pub fn into_value(self) -> Value { + self.0 + } +} + +#[derive(Clone, PartialEq)] +pub struct OcrOutcome { + pub normalized: NormalizedOcr, + pub native: NativeOcrPayload, +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn normalized_serialization_excludes_the_native_payload() { + let outcome = OcrOutcome { + normalized: NormalizedOcr { + pages: vec![OcrPage { + index: 0, + markdown: "portable".to_string(), + }], + model: "ocr-model".to_string(), + document_annotation: None, + content: None, + tables: None, + key_value_pairs: None, + usage: OcrUsage::default(), + }, + native: NativeOcrPayload::new(json!({"provider_secret_field": "native"})), + }; + let public = + serde_json::to_value(&outcome.normalized).expect("normalized output serializes"); + assert_eq!(public["pages"][0]["markdown"], "portable"); + assert!(public.get("provider_secret_field").is_none()); + assert_eq!(outcome.native.as_value()["provider_secret_field"], "native"); + } +} diff --git a/litellm-rust/crates/core/src/ocr/types.rs b/litellm-rust/crates/core/src/ocr/types.rs index 1a72b8f1d66..e5ed78987e8 100644 --- a/litellm-rust/crates/core/src/ocr/types.rs +++ b/litellm-rust/crates/core/src/ocr/types.rs @@ -1,6 +1,34 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Field { + Absent, + Null, + Value(T), +} + +impl Field { + pub fn as_ref(&self) -> Field<&T> { + match self { + Self::Absent => Field::Absent, + Self::Null => Field::Null, + Self::Value(value) => Field::Value(value), + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum OcrDialectId { + Mistral, + AzureFoundryMistral, + AzureDocumentIntelligence, + VertexMistral, + VertexDeepSeek, + ReductoV3, + ReductoLegacy, +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct OcrRequestData { pub data: Value,