From 55fe8d40d0a8d5c0a35a25eec171027c62d2548c Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Tue, 15 Sep 2026 11:09:59 -0700 Subject: [PATCH] refactor(ocr): flatten Azure transformation --- .../document_intelligence/transformation.rs | 1130 ++++++++--------- 1 file changed, 541 insertions(+), 589 deletions(-) diff --git a/litellm-rust/crates/core/src/llms/azure_ai/ocr/document_intelligence/transformation.rs b/litellm-rust/crates/core/src/llms/azure_ai/ocr/document_intelligence/transformation.rs index ca95ca1987a..c27cc849a49 100644 --- a/litellm-rust/crates/core/src/llms/azure_ai/ocr/document_intelligence/transformation.rs +++ b/litellm-rust/crates/core/src/llms/azure_ai/ocr/document_intelligence/transformation.rs @@ -1,629 +1,491 @@ -mod types { - use serde::{Deserialize, Deserializer, Serialize}; - use serde_json::{Map, Value}; +use std::collections::BTreeSet; +use std::sync::Arc; +use std::time::Duration; - #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] - #[serde(untagged)] - pub(crate) enum PagesInput { - ZeroBasedIndices(Vec), - NativeTokens(Vec), - NativeRange(String), - } +use base64::{Engine, engine::general_purpose::STANDARD}; +use reqwest::Url; +use serde::{Deserialize, Deserializer, Serialize}; +use serde_json::{Map, Value, json}; +use tokio::time::Instant; - #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] - #[serde(untagged)] - pub(crate) enum FeaturesInput { - Names(Vec), - CommaSeparated(String), - } +use litellm_auth::{InputSource, Sourced}; +use litellm_auth_azure::AzureAuthInputs; - #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] - pub(crate) struct DocumentIntelligenceInputParams { - pub pages: Option, - pub features: Option, - } - - #[derive(Clone, Debug, PartialEq, Serialize)] - pub(crate) struct DocumentIntelligenceParams { - pub pages: Option, - pub features: Option, - } - - #[derive(Clone, Debug, Serialize, Deserialize)] - #[serde(untagged)] - pub(crate) enum DocumentIntelligenceRequest { - UrlSource { - #[serde(rename = "urlSource")] - url_source: String, - }, - Base64Source { - #[serde(rename = "base64Source")] - base64_source: String, - }, - } - - #[derive(Clone, Debug, PartialEq)] - pub(crate) enum OperationStatus { - Succeeded, - Running, - NotStarted, - Failed, - Unknown(String), - } - - impl<'de> Deserialize<'de> for OperationStatus { - fn deserialize>(deserializer: D) -> Result { - Ok(match String::deserialize(deserializer)?.as_str() { - "succeeded" => Self::Succeeded, - "running" => Self::Running, - "notStarted" => Self::NotStarted, - "failed" => Self::Failed, - value => Self::Unknown(value.to_string()), - }) - } - } - - impl std::fmt::Display for OperationStatus { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter.write_str(match self { - Self::Succeeded => "succeeded", - Self::Running => "running", - Self::NotStarted => "notStarted", - Self::Failed => "failed", - Self::Unknown(value) => value, - }) - } - } - - #[derive(Clone, Debug, Deserialize)] - pub(crate) struct AzureDocumentIntelligenceOperation { - pub status: Option, - #[serde(rename = "analyzeResult")] - pub analyze_result: Option, - } - - #[derive(Clone, Debug, Default, Deserialize)] - pub(crate) struct AzureDocumentIntelligenceAnalyzeResult { - pub content: Option, - #[serde(default)] - pub pages: Vec, - pub tables: Option>>, - #[serde(rename = "keyValuePairs")] - pub key_value_pairs: Option>>, - } - - #[derive(Clone, Debug, Deserialize)] - pub(crate) struct AzureDocumentIntelligencePage { - #[serde(rename = "pageNumber", default, deserialize_with = "optional_i64")] - pub page_number: Option, - #[serde(default, deserialize_with = "optional_f64")] - pub width: Option, - #[serde(default, deserialize_with = "optional_f64")] - pub height: Option, - pub unit: Option, - #[serde(default)] - pub lines: Vec, - } - - #[derive(Clone, Debug, Deserialize)] - pub(crate) struct AzureDocumentIntelligenceLine { - pub content: Option, - } - - fn optional_i64<'de, D: Deserializer<'de>>(deserializer: D) -> Result, D::Error> { - match Option::::deserialize(deserializer)? { - None | Some(Value::Null) => Ok(None), - Some(Value::Number(number)) => number - .as_i64() - .map(Some) - .ok_or_else(|| serde::de::Error::custom("expected an integer")), - Some(Value::String(value)) => value - .parse::() - .map(Some) - .map_err(|_| serde::de::Error::custom("expected an integer")), - Some(_) => Err(serde::de::Error::custom("expected an integer")), - } - } - - fn optional_f64<'de, D: Deserializer<'de>>(deserializer: D) -> Result, D::Error> { - match Option::::deserialize(deserializer)? { - None | Some(Value::Null) => Ok(None), - Some(Value::Number(number)) => number - .as_f64() - .filter(|value| value.is_finite()) - .map(Some) - .ok_or_else(|| serde::de::Error::custom("expected a finite number")), - Some(Value::String(value)) => value - .parse::() - .ok() - .filter(|value| value.is_finite()) - .map(Some) - .ok_or_else(|| serde::de::Error::custom("expected a finite number")), - Some(_) => Err(serde::de::Error::custom("expected a number")), - } - } -} - -pub(crate) use types::{ - AzureDocumentIntelligenceOperation, DocumentIntelligenceParams, OperationStatus, +use crate::Error; +use crate::constants::{ + AZURE_DI_API_VERSION, AZURE_DI_DEFAULT_DPI, AZURE_DI_DEFAULT_HEIGHT, AZURE_DI_DEFAULT_WIDTH, + AZURE_DI_SUBSCRIPTION_HEADER, OCR_POLL_RETRY_SECS, }; +use crate::llms::base_llm::ocr::transformation::BaseOcrConfig; +use crate::ocr::OcrClient; +use crate::ocr::client::read_json_response; +use crate::ocr::document::InlineDocument; +use crate::ocr::error::{OcrError, OcrPollingError, OcrRequestError, OcrResponseError}; +use crate::ocr::hooks::OcrHooks; +use crate::ocr::prepare::{ParsedProviderParams, credential_env, transform_request_body}; +use crate::ocr::types::{ + LiteLLMOcrRequest, LiteLLMOcrResponse, OcrConnection, OcrDocument, OcrResponseFormat, +}; +use crate::ocr::wire::DecodedOcrResponse; +use crate::url_utils::ApiUrl; -mod params { - use std::collections::BTreeSet; +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +enum PagesInput { + ZeroBasedIndices(Vec), + NativeTokens(Vec), + NativeRange(String), +} - use serde_json::{Map, Value}; +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +enum FeaturesInput { + Names(Vec), + CommaSeparated(String), +} - use super::types::{ - DocumentIntelligenceInputParams, DocumentIntelligenceParams, FeaturesInput, PagesInput, - }; - use crate::ocr::error::OcrRequestError; - use crate::ocr::prepare::ParsedProviderParams; +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +struct DocumentIntelligenceInputParams { + pub pages: Option, + pub features: Option, +} - pub(crate) fn decode_input_params( - params: Map, - prefix: &str, - ) -> Result, OcrRequestError> { - if let Some(Value::Array(pages)) = params.get("pages") { - if pages.iter().any(Value::is_boolean) { - return Err(OcrRequestError::Pages("boolean page index".into())); - } - if pages - .iter() - .any(|page| page.is_number() && page.as_i64().is_none()) - { - return Err(OcrRequestError::Pages("page index is out of range".into())); - } - if !pages.iter().all(Value::is_i64) && !pages.iter().all(Value::is_string) { - return Err(OcrRequestError::Pages("mixed page element types".into())); - } - } - crate::ocr::wire::decode_request_value(Value::Object(params), prefix) - } +#[derive(Clone, Debug, PartialEq, Serialize)] +struct DocumentIntelligenceParams { + pub pages: Option, + pub features: Option, +} - pub(crate) fn map_ocr_params( - params: DocumentIntelligenceInputParams, - ) -> Result { - Ok(DocumentIntelligenceParams { - pages: params.pages.map(normalize_pages).transpose()?.flatten(), - features: params - .features - .map(normalize_features) - .transpose()? - .flatten(), +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(untagged)] +enum DocumentIntelligenceRequest { + UrlSource { + #[serde(rename = "urlSource")] + url_source: String, + }, + Base64Source { + #[serde(rename = "base64Source")] + base64_source: String, + }, +} + +#[derive(Clone, Debug, PartialEq)] +enum OperationStatus { + Succeeded, + Running, + NotStarted, + Failed, + Unknown(String), +} + +impl<'de> Deserialize<'de> for OperationStatus { + fn deserialize>(deserializer: D) -> Result { + Ok(match String::deserialize(deserializer)?.as_str() { + "succeeded" => Self::Succeeded, + "running" => Self::Running, + "notStarted" => Self::NotStarted, + "failed" => Self::Failed, + value => Self::Unknown(value.to_string()), }) } - - fn normalize_pages(pages: PagesInput) -> Result, OcrRequestError> { - let normalized = match pages { - PagesInput::ZeroBasedIndices(indices) => { - if indices.is_empty() { - return Ok(None); - } - indices - .into_iter() - .map(|page| { - if page < 0 { - return Err(OcrRequestError::Pages("negative page index".into())); - } - page.checked_add(1).ok_or_else(|| { - OcrRequestError::Pages("page index is out of range".into()) - }) - }) - .collect::, _>>()? - .into_iter() - .map(|page| page.to_string()) - .collect::>() - .join(",") - } - PagesInput::NativeTokens(tokens) => { - if tokens.is_empty() { - return Ok(None); - } - tokens - .iter() - .map(|token| token.trim()) - .collect::>() - .join(",") - } - PagesInput::NativeRange(range) => range - .split(',') - .map(str::trim) - .collect::>() - .join(","), - }; - if !normalized.split(',').all(valid_page_token) { - return Err(OcrRequestError::Pages("invalid native page range".into())); - } - Ok(Some(normalized)) - } - - fn valid_page_token(token: &str) -> bool { - let mut parts = token.split('-'); - let start = parts.next().unwrap_or_default(); - if start.is_empty() || !start.chars().all(|character| character.is_ascii_digit()) { - return false; - } - match parts.next() { - None => true, - Some(end) => { - !end.is_empty() - && end.chars().all(|character| character.is_ascii_digit()) - && parts.next().is_none() - } - } - } - - fn normalize_features(features: FeaturesInput) -> Result, OcrRequestError> { - let tokens = match features { - FeaturesInput::Names(names) => names, - FeaturesInput::CommaSeparated(names) => names.split(',').map(str::to_string).collect(), - }; - if tokens.is_empty() { - return Ok(None); - } - let normalized = tokens.iter().map(|token| token.trim()).collect::>(); - if !normalized.iter().all(|token| { - let Some((first, rest)) = token.as_bytes().split_first() else { - return false; - }; - first.is_ascii_alphabetic() && rest.iter().all(u8::is_ascii_alphanumeric) - }) { - return Err(OcrRequestError::Features); - } - Ok(Some(normalized.join(","))) - } - - #[cfg(test)] - mod tests { - use rstest::rstest; - use serde_json::{Value, json}; - - use super::*; - - fn map(value: Value) -> Result { - let fields = value.as_object().unwrap().clone(); - map_ocr_params(decode_input_params(fields, "optional_params")?.known) - } - - #[test] - fn input_params_retain_unknown_fields() { - let parsed = decode_input_params( - json!({ - "pages": [0], - "future_ocr_option": true, - "extra_body": {"provider_option": "value"} - }) - .as_object() - .unwrap() - .clone(), - "optional_params", - ) - .unwrap(); - - assert_eq!( - parsed.known.pages, - Some(PagesInput::ZeroBasedIndices(vec![0])) - ); - assert_eq!(parsed.extra_params["future_ocr_option"], true); - assert_eq!( - parsed.extra_params["extra_body"], - json!({"provider_option": "value"}) - ); - assert_eq!( - serde_json::to_value(map_ocr_params(parsed.known).unwrap()).unwrap(), - json!({"pages": "1", "features": null}) - ); - } - - #[rstest] - #[case(json!([0, 1, 2]), Some("1,2,3"))] - #[case(json!([2, 0, 0, 1]), Some("1,2,3"))] - #[case(json!([]), None)] - #[case(json!("3-9"), Some("3-9"))] - #[case(json!("1-3, 5"), Some("1-3,5"))] - #[case(json!(["1", "3-5"]), Some("1,3-5"))] - fn page_mapping_matches_python(#[case] input: Value, #[case] expected: Option<&str>) { - assert_eq!( - map(json!({"pages": input})).unwrap().pages.as_deref(), - expected - ); - } - - #[rstest] - #[case(json!("a,b"))] - #[case(json!([-1]))] - #[case(json!([true, false]))] - #[case(json!([1, "2"]))] - #[case(json!(5))] - fn invalid_page_mapping_matches_python(#[case] input: Value) { - assert!(map(json!({"pages": input})).is_err()); - } - - #[rstest] - #[case(json!(["keyValuePairs"]), "keyValuePairs")] - #[case(json!(["keyValuePairs", "languages"]), "keyValuePairs,languages")] - #[case(json!("keyValuePairs"), "keyValuePairs")] - #[case(json!("keyValuePairs,languages"), "keyValuePairs,languages")] - #[case(json!("keyValuePairs, languages"), "keyValuePairs,languages")] - fn feature_mapping_matches_python(#[case] input: Value, #[case] expected: &str) { - assert_eq!( - map(json!({"features": input})).unwrap().features.as_deref(), - Some(expected) - ); - } - - #[rstest] - #[case(json!("keyValuePairs&pages=9"))] - #[case(json!("key value pairs"))] - #[case(json!(""))] - #[case(json!([1, 2]))] - #[case(json!([["keyValuePairs"]]))] - #[case(json!({"feature":"keyValuePairs"}))] - #[case(json!(5))] - fn invalid_feature_mapping_matches_python(#[case] input: Value) { - assert!(map(json!({"features": input})).is_err()); - } - - #[test] - fn empty_feature_list_is_omitted() { - assert_eq!(map(json!({"features": []})).unwrap().features, None); - } - } } -mod mapping { - use base64::{Engine, engine::general_purpose::STANDARD}; - use serde_json::{Map, Value, json}; - - use super::types::*; - use crate::constants::{AZURE_DI_DEFAULT_DPI, AZURE_DI_DEFAULT_HEIGHT, AZURE_DI_DEFAULT_WIDTH}; - use crate::ocr::document::InlineDocument; - use crate::ocr::error::{OcrRequestError, OcrResponseError}; - use crate::ocr::types::{LiteLLMOcrResponse, OcrDocument}; - - #[tracing::instrument(target = "litellm::function_trace", level = "trace", skip_all)] - pub(crate) fn transform_ocr_request( - document: OcrDocument, - ) -> Result { - let source = document.source(); - if source.is_empty() { - return Err(OcrRequestError::MissingDocumentUrl); - } - Ok(if let Some(document) = InlineDocument::parse(source)? { - DocumentIntelligenceRequest::Base64Source { - base64_source: STANDARD - .encode(document.decode(crate::constants::OCR_INLINE_MAX_BYTES)?), - } - } else { - DocumentIntelligenceRequest::UrlSource { - url_source: source.to_string(), - } +impl std::fmt::Display for OperationStatus { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(match self { + Self::Succeeded => "succeeded", + Self::Running => "running", + Self::NotStarted => "notStarted", + Self::Failed => "failed", + Self::Unknown(value) => value, }) } +} - pub(crate) fn transform_ocr_response( - model: &str, - response: AzureDocumentIntelligenceOperation, - ) -> Result { - if response.status != Some(OperationStatus::Succeeded) { - return Err(OcrResponseError::OperationStatus( - response - .status - .map(|status| status.to_string()) - .unwrap_or_else(|| "None".into()), - )); - } - let result = response.analyze_result.unwrap_or_default(); - let pages = result - .pages - .into_iter() - .map(normalize_page) - .collect::, _>>()?; - let pages_processed = pages.len(); - let mut extra_fields = Map::new(); - extra_fields.insert("content".into(), option_value(result.content)); - extra_fields.insert("tables".into(), option_value(result.tables)); - extra_fields.insert("keyValuePairs".into(), option_value(result.key_value_pairs)); - Ok(LiteLLMOcrResponse { - pages, - model: model.into(), - document_annotation: None, - usage_info: Some(json!({"pages_processed":pages_processed})), - object: "ocr".into(), - extra_fields, - provider_native_response: None, - }) +#[derive(Clone, Debug, Deserialize)] +pub(crate) struct AzureDocumentIntelligenceOperation { + status: Option, + #[serde(rename = "analyzeResult")] + analyze_result: Option, +} + +#[derive(Clone, Debug, Default, Deserialize)] +struct AzureDocumentIntelligenceAnalyzeResult { + pub content: Option, + #[serde(default)] + pub pages: Vec, + pub tables: Option>>, + #[serde(rename = "keyValuePairs")] + pub key_value_pairs: Option>>, +} + +#[derive(Clone, Debug, Deserialize)] +struct AzureDocumentIntelligencePage { + #[serde(rename = "pageNumber", default, deserialize_with = "optional_i64")] + pub page_number: Option, + #[serde(default, deserialize_with = "optional_f64")] + pub width: Option, + #[serde(default, deserialize_with = "optional_f64")] + pub height: Option, + pub unit: Option, + #[serde(default)] + pub lines: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +struct AzureDocumentIntelligenceLine { + pub content: Option, +} + +fn optional_i64<'de, D: Deserializer<'de>>(deserializer: D) -> Result, D::Error> { + match Option::::deserialize(deserializer)? { + None | Some(Value::Null) => Ok(None), + Some(Value::Number(number)) => number + .as_i64() + .map(Some) + .ok_or_else(|| serde::de::Error::custom("expected an integer")), + Some(Value::String(value)) => value + .parse::() + .map(Some) + .map_err(|_| serde::de::Error::custom("expected an integer")), + Some(_) => Err(serde::de::Error::custom("expected an integer")), } +} - fn normalize_page(page: AzureDocumentIntelligencePage) -> Result { - let index = page - .page_number - .unwrap_or(1) - .checked_sub(1) - .ok_or(OcrResponseError::NumericRange("page.pageNumber"))?; - let scale = if page.unit.as_deref().unwrap_or("inch") == "inch" { - AZURE_DI_DEFAULT_DPI as f64 - } else { - 1.0 - }; - let width = pixel_dimension( - page.width.unwrap_or(AZURE_DI_DEFAULT_WIDTH), - scale, - "page.width", - )?; - let height = pixel_dimension( - page.height.unwrap_or(AZURE_DI_DEFAULT_HEIGHT), - scale, - "page.height", - )?; - let markdown = page - .lines +fn optional_f64<'de, D: Deserializer<'de>>(deserializer: D) -> Result, D::Error> { + match Option::::deserialize(deserializer)? { + None | Some(Value::Null) => Ok(None), + Some(Value::Number(number)) => number + .as_f64() + .filter(|value| value.is_finite()) + .map(Some) + .ok_or_else(|| serde::de::Error::custom("expected a finite number")), + Some(Value::String(value)) => value + .parse::() + .ok() + .filter(|value| value.is_finite()) + .map(Some) + .ok_or_else(|| serde::de::Error::custom("expected a finite number")), + Some(_) => Err(serde::de::Error::custom("expected a number")), + } +} + +fn decode_input_params( + params: Map, + prefix: &str, +) -> Result, OcrRequestError> { + if let Some(Value::Array(pages)) = params.get("pages") { + if pages.iter().any(Value::is_boolean) { + return Err(OcrRequestError::Pages("boolean page index".into())); + } + if pages .iter() - .map(|line| line.content.as_deref().unwrap_or_default()) - .collect::>() - .join("\n"); - Ok(json!({ - "index":index, - "markdown":markdown, - "images":null, - "dimensions":{"width":width,"height":height,"dpi":AZURE_DI_DEFAULT_DPI} - })) - } - - fn pixel_dimension( - value: f64, - scale: f64, - field: &'static str, - ) -> Result { - let value = value * scale; - if !value.is_finite() || value < i64::MIN as f64 || value > i64::MAX as f64 { - return Err(OcrResponseError::NumericRange(field)); + .any(|page| page.is_number() && page.as_i64().is_none()) + { + return Err(OcrRequestError::Pages("page index is out of range".into())); + } + if !pages.iter().all(Value::is_i64) && !pages.iter().all(Value::is_string) { + return Err(OcrRequestError::Pages("mixed page element types".into())); } - Ok(value.trunc() as i64) } + crate::ocr::wire::decode_request_value(Value::Object(params), prefix) +} - fn option_value(value: Option) -> Value { - value - .and_then(|value| serde_json::to_value(value).ok()) - .unwrap_or(Value::Null) +fn normalize_ocr_params( + params: DocumentIntelligenceInputParams, +) -> Result { + Ok(DocumentIntelligenceParams { + pages: params.pages.map(normalize_pages).transpose()?.flatten(), + features: params + .features + .map(normalize_features) + .transpose()? + .flatten(), + }) +} + +fn normalize_pages(pages: PagesInput) -> Result, OcrRequestError> { + let normalized = match pages { + PagesInput::ZeroBasedIndices(indices) => { + if indices.is_empty() { + return Ok(None); + } + indices + .into_iter() + .map(|page| { + if page < 0 { + return Err(OcrRequestError::Pages("negative page index".into())); + } + page.checked_add(1) + .ok_or_else(|| OcrRequestError::Pages("page index is out of range".into())) + }) + .collect::, _>>()? + .into_iter() + .map(|page| page.to_string()) + .collect::>() + .join(",") + } + PagesInput::NativeTokens(tokens) => { + if tokens.is_empty() { + return Ok(None); + } + tokens + .iter() + .map(|token| token.trim()) + .collect::>() + .join(",") + } + PagesInput::NativeRange(range) => range + .split(',') + .map(str::trim) + .collect::>() + .join(","), + }; + if !normalized.split(',').all(valid_page_token) { + return Err(OcrRequestError::Pages("invalid native page range".into())); + } + Ok(Some(normalized)) +} + +fn valid_page_token(token: &str) -> bool { + let mut parts = token.split('-'); + let start = parts.next().unwrap_or_default(); + if start.is_empty() || !start.chars().all(|character| character.is_ascii_digit()) { + return false; + } + match parts.next() { + None => true, + Some(end) => { + !end.is_empty() + && end.chars().all(|character| character.is_ascii_digit()) + && parts.next().is_none() + } } } -mod polling { - use std::sync::Arc; - use std::time::Duration; +fn normalize_features(features: FeaturesInput) -> Result, OcrRequestError> { + let tokens = match features { + FeaturesInput::Names(names) => names, + FeaturesInput::CommaSeparated(names) => names.split(',').map(str::to_string).collect(), + }; + if tokens.is_empty() { + return Ok(None); + } + let normalized = tokens.iter().map(|token| token.trim()).collect::>(); + if !normalized.iter().all(|token| { + let Some((first, rest)) = token.as_bytes().split_first() else { + return false; + }; + first.is_ascii_alphabetic() && rest.iter().all(u8::is_ascii_alphanumeric) + }) { + return Err(OcrRequestError::Features); + } + Ok(Some(normalized.join(","))) +} - use reqwest::Url; - use tokio::time::Instant; - - use super::{AzureDocumentIntelligenceOperation, OperationStatus}; - use crate::constants::{AZURE_DI_SUBSCRIPTION_HEADER, OCR_POLL_RETRY_SECS}; - use crate::ocr::client::read_json_response; - use crate::ocr::error::{OcrError, OcrPollingError, OcrResponseError}; - use crate::ocr::hooks::OcrHooks; - use crate::ocr::types::OcrConnection; - use crate::ocr::wire::DecodedOcrResponse; - - pub(super) async fn read_operation_response( - http_client: &reqwest::Client, - response: reqwest::Response, - original_url: &str, - headers: &[(String, String)], - connection: &OcrConnection, - native: bool, - hooks: &Arc, - ) -> Result, OcrError> { - if response.status() != reqwest::StatusCode::ACCEPTED { - let bytes = - crate::ocr::client::read_response_bytes(response, connection.max_response_bytes) - .await?; - crate::ocr::handler::post_call(hooks, &bytes).await?; - return Ok(crate::ocr::wire::decode_response(&bytes, native)?); +#[tracing::instrument(target = "litellm::function_trace", level = "trace", skip_all)] +fn transform_ocr_request( + document: OcrDocument, +) -> Result { + let source = document.source(); + if source.is_empty() { + return Err(OcrRequestError::MissingDocumentUrl); + } + Ok(if let Some(document) = InlineDocument::parse(source)? { + DocumentIntelligenceRequest::Base64Source { + base64_source: STANDARD + .encode(document.decode(crate::constants::OCR_INLINE_MAX_BYTES)?), } - let location = response - .headers() - .get("operation-location") - .and_then(|value| value.to_str().ok()) - .ok_or(OcrPollingError::PollLocation)? - .to_string(); - let original = Url::parse(original_url).map_err(|_| OcrPollingError::PollOrigin)?; - let operation = Url::parse(&location).map_err(|_| OcrPollingError::PollOrigin)?; - if original.origin() != operation.origin() - || !operation.username().is_empty() - || operation.password().is_some() - { - return Err(OcrPollingError::PollOrigin.into()); + } else { + DocumentIntelligenceRequest::UrlSource { + url_source: source.to_string(), } + }) +} + +fn transform_ocr_response( + model: &str, + response: AzureDocumentIntelligenceOperation, +) -> Result { + if response.status != Some(OperationStatus::Succeeded) { + return Err(OcrResponseError::OperationStatus( + response + .status + .map(|status| status.to_string()) + .unwrap_or_else(|| "None".into()), + )); + } + let result = response.analyze_result.unwrap_or_default(); + let pages = result + .pages + .into_iter() + .map(normalize_page) + .collect::, _>>()?; + let pages_processed = pages.len(); + let mut extra_fields = Map::new(); + extra_fields.insert("content".into(), option_value(result.content)); + extra_fields.insert("tables".into(), option_value(result.tables)); + extra_fields.insert("keyValuePairs".into(), option_value(result.key_value_pairs)); + Ok(LiteLLMOcrResponse { + pages, + model: model.into(), + document_annotation: None, + usage_info: Some(json!({"pages_processed":pages_processed})), + object: "ocr".into(), + extra_fields, + provider_native_response: None, + }) +} + +fn normalize_page(page: AzureDocumentIntelligencePage) -> Result { + let index = page + .page_number + .unwrap_or(1) + .checked_sub(1) + .ok_or(OcrResponseError::NumericRange("page.pageNumber"))?; + let scale = if page.unit.as_deref().unwrap_or("inch") == "inch" { + AZURE_DI_DEFAULT_DPI as f64 + } else { + 1.0 + }; + let width = pixel_dimension( + page.width.unwrap_or(AZURE_DI_DEFAULT_WIDTH), + scale, + "page.width", + )?; + let height = pixel_dimension( + page.height.unwrap_or(AZURE_DI_DEFAULT_HEIGHT), + scale, + "page.height", + )?; + let markdown = page + .lines + .iter() + .map(|line| line.content.as_deref().unwrap_or_default()) + .collect::>() + .join("\n"); + Ok(json!({ + "index":index, + "markdown":markdown, + "images":null, + "dimensions":{"width":width,"height":height,"dpi":AZURE_DI_DEFAULT_DPI} + })) +} + +fn pixel_dimension(value: f64, scale: f64, field: &'static str) -> Result { + let value = value * scale; + if !value.is_finite() || value < i64::MIN as f64 || value > i64::MAX as f64 { + return Err(OcrResponseError::NumericRange(field)); + } + Ok(value.trunc() as i64) +} + +fn option_value(value: Option) -> Value { + value + .and_then(|value| serde_json::to_value(value).ok()) + .unwrap_or(Value::Null) +} +async fn read_operation_response( + http_client: &reqwest::Client, + response: reqwest::Response, + original_url: &str, + headers: &[(String, String)], + connection: &OcrConnection, + native: bool, + hooks: &Arc, +) -> Result, OcrError> { + if response.status() != reqwest::StatusCode::ACCEPTED { let bytes = crate::ocr::client::read_response_bytes(response, connection.max_response_bytes) .await?; crate::ocr::handler::post_call(hooks, &bytes).await?; - poll_operation(http_client, operation, headers, connection, native).await + return Ok(crate::ocr::wire::decode_response(&bytes, native)?); } + let location = response + .headers() + .get("operation-location") + .and_then(|value| value.to_str().ok()) + .ok_or(OcrPollingError::PollLocation)? + .to_string(); + let original = Url::parse(original_url).map_err(|_| OcrPollingError::PollOrigin)?; + let operation = Url::parse(&location).map_err(|_| OcrPollingError::PollOrigin)?; + if original.origin() != operation.origin() + || !operation.username().is_empty() + || operation.password().is_some() + { + return Err(OcrPollingError::PollOrigin.into()); + } + let bytes = + crate::ocr::client::read_response_bytes(response, connection.max_response_bytes).await?; + crate::ocr::handler::post_call(hooks, &bytes).await?; + poll_operation(http_client, operation, headers, connection, native).await +} - async fn poll_operation( - http_client: &reqwest::Client, - url: Url, - headers: &[(String, String)], - connection: &OcrConnection, - native: bool, - ) -> Result, OcrError> { - let deadline = Instant::now() - .checked_add(connection.poll_timeout) +async fn poll_operation( + http_client: &reqwest::Client, + url: Url, + headers: &[(String, String)], + connection: &OcrConnection, + native: bool, +) -> Result, OcrError> { + let deadline = Instant::now() + .checked_add(connection.poll_timeout) + .ok_or(OcrPollingError::PollTimeout)?; + + loop { + let remaining = deadline + .checked_duration_since(Instant::now()) + .filter(|remaining| !remaining.is_zero()) .ok_or(OcrPollingError::PollTimeout)?; - loop { - let remaining = deadline - .checked_duration_since(Instant::now()) - .filter(|remaining| !remaining.is_zero()) - .ok_or(OcrPollingError::PollTimeout)?; - let builder = http_client - .get(url.clone()) - .timeout(remaining.min(connection.timeout)); - let builder = crate::http_utils::with_headers( - builder, - headers, - crate::http_utils::HeaderPolicy::Only(&[ - AZURE_DI_SUBSCRIPTION_HEADER, - "authorization", - ]), - ); - let response = - tokio::time::timeout_at(deadline, crate::http_utils::http_request(builder)) - .await - .map_err(|_| OcrPollingError::PollTimeout)? - .map_err(crate::error::TransportError::from)?; - let retry = response - .headers() - .get(reqwest::header::RETRY_AFTER) - .and_then(|value| value.to_str().ok()) - .and_then(|value| value.parse::().ok()) - .unwrap_or(OCR_POLL_RETRY_SECS) - .max(1); - let decoded = tokio::time::timeout_at( - deadline, - read_json_response::( - response, - native, - connection.max_response_bytes, - ), - ) + let builder = http_client + .get(url.clone()) + .timeout(remaining.min(connection.timeout)); + let builder = crate::http_utils::with_headers( + builder, + headers, + crate::http_utils::HeaderPolicy::Only(&[AZURE_DI_SUBSCRIPTION_HEADER, "authorization"]), + ); + let response = tokio::time::timeout_at(deadline, crate::http_utils::http_request(builder)) .await - .map_err(|_| OcrPollingError::PollTimeout)??; - match &decoded.data.status { - Some(OperationStatus::Succeeded) => return Ok(decoded), - Some(OperationStatus::Running | OperationStatus::NotStarted) => { - tokio::time::timeout_at( - deadline, - tokio::time::sleep(Duration::from_secs(retry)), - ) + .map_err(|_| OcrPollingError::PollTimeout)? + .map_err(crate::error::TransportError::from)?; + let retry = response + .headers() + .get(reqwest::header::RETRY_AFTER) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse::().ok()) + .unwrap_or(OCR_POLL_RETRY_SECS) + .max(1); + let decoded = tokio::time::timeout_at( + deadline, + read_json_response::( + response, + native, + connection.max_response_bytes, + ), + ) + .await + .map_err(|_| OcrPollingError::PollTimeout)??; + match &decoded.data.status { + Some(OperationStatus::Succeeded) => return Ok(decoded), + Some(OperationStatus::Running | OperationStatus::NotStarted) => { + tokio::time::timeout_at(deadline, tokio::time::sleep(Duration::from_secs(retry))) .await .map_err(|_| OcrPollingError::PollTimeout)?; - } - status => { - return Err(OcrResponseError::OperationStatus( - status - .as_ref() - .map(ToString::to_string) - .unwrap_or_else(|| "None".into()), - ) - .into()); - } + } + status => { + return Err(OcrResponseError::OperationStatus( + status + .as_ref() + .map(ToString::to_string) + .unwrap_or_else(|| "None".into()), + ) + .into()); } } } } -use crate::Error; -use crate::constants::{AZURE_DI_API_VERSION, AZURE_DI_SUBSCRIPTION_HEADER}; -use crate::llms::base_llm::ocr::transformation::BaseOcrConfig; -use crate::ocr::OcrClient; -use crate::ocr::error::{OcrError, OcrRequestError, OcrResponseError}; -use crate::ocr::prepare::{credential_env, transform_request_body}; -use crate::ocr::types::{LiteLLMOcrRequest, LiteLLMOcrResponse, OcrConnection, OcrResponseFormat}; -use crate::url_utils::ApiUrl; -use litellm_auth::{InputSource, Sourced}; -use litellm_auth_azure::AzureAuthInputs; - const AZURE_DI_API_KEY_ENV: &str = "AZURE_DOCUMENT_INTELLIGENCE_API_KEY"; const AZURE_DI_ENDPOINT_ENV: &str = "AZURE_DOCUMENT_INTELLIGENCE_ENDPOINT"; @@ -656,7 +518,7 @@ impl BaseOcrConfig for AzureDocumentIntelligenceOCRConfig { .or_else(|| nonblank(credential_env(AZURE_DI_ENDPOINT_ENV))) .ok_or_else(|| Error::Auth("Missing Azure Document Intelligence API Base - Set AZURE_DOCUMENT_INTELLIGENCE_ENDPOINT or pass api_base".into()))?; let url = get_complete_url(&endpoint, &request.model, ¶ms)?; - let body = mapping::transform_ocr_request(request.document.clone())?; + let body = transform_ocr_request(request.document.clone())?; transform_request_body(client, request, &url, &headers, false, body, |_| Ok(())).await } @@ -665,7 +527,7 @@ impl BaseOcrConfig for AzureDocumentIntelligenceOCRConfig { request: &LiteLLMOcrRequest, response: AzureDocumentIntelligenceOperation, ) -> Result { - mapping::transform_ocr_response(&request.model, response) + transform_ocr_response(&request.model, response) } async fn read_response( @@ -677,7 +539,7 @@ impl BaseOcrConfig for AzureDocumentIntelligenceOCRConfig { request: &LiteLLMOcrRequest, ) -> Result, OcrError> { - polling::read_operation_response( + read_operation_response( client.polling_http(), response, url, @@ -694,13 +556,12 @@ impl BaseOcrConfig for AzureDocumentIntelligenceOCRConfig { fn map_ocr_params( request: &LiteLLMOcrRequest, ) -> Result { - let params = - params::decode_input_params(request.optional_params.clone().into(), "optional_params")?; + let params = decode_input_params(request.optional_params.clone().into(), "optional_params")?; let crate::ocr::prepare::ParsedProviderParams { known: params, extra_params: _extra_params, } = params; - params::map_ocr_params(params) + normalize_ocr_params(params) } fn get_complete_url( @@ -787,6 +648,97 @@ fn nonblank(value: Option) -> Option { #[cfg(test)] mod tests { use super::*; + use rstest::rstest; + use serde_json::{Value, json}; + + fn map(value: Value) -> Result { + let fields = value.as_object().unwrap().clone(); + normalize_ocr_params(decode_input_params(fields, "optional_params")?.known) + } + + #[test] + fn input_params_retain_unknown_fields() { + let parsed = decode_input_params( + json!({ + "pages": [0], + "future_ocr_option": true, + "extra_body": {"provider_option": "value"} + }) + .as_object() + .unwrap() + .clone(), + "optional_params", + ) + .unwrap(); + + assert_eq!( + parsed.known.pages, + Some(PagesInput::ZeroBasedIndices(vec![0])) + ); + assert_eq!(parsed.extra_params["future_ocr_option"], true); + assert_eq!( + parsed.extra_params["extra_body"], + json!({"provider_option": "value"}) + ); + assert_eq!( + serde_json::to_value(normalize_ocr_params(parsed.known).unwrap()).unwrap(), + json!({"pages": "1", "features": null}) + ); + } + + #[rstest] + #[case(json!([0, 1, 2]), Some("1,2,3"))] + #[case(json!([2, 0, 0, 1]), Some("1,2,3"))] + #[case(json!([]), None)] + #[case(json!("3-9"), Some("3-9"))] + #[case(json!("1-3, 5"), Some("1-3,5"))] + #[case(json!(["1", "3-5"]), Some("1,3-5"))] + fn page_mapping_matches_python(#[case] input: Value, #[case] expected: Option<&str>) { + assert_eq!( + map(json!({"pages": input})).unwrap().pages.as_deref(), + expected + ); + } + + #[rstest] + #[case(json!("a,b"))] + #[case(json!([-1]))] + #[case(json!([true, false]))] + #[case(json!([1, "2"]))] + #[case(json!(5))] + fn invalid_page_mapping_matches_python(#[case] input: Value) { + assert!(map(json!({"pages": input})).is_err()); + } + + #[rstest] + #[case(json!(["keyValuePairs"]), "keyValuePairs")] + #[case(json!(["keyValuePairs", "languages"]), "keyValuePairs,languages")] + #[case(json!("keyValuePairs"), "keyValuePairs")] + #[case(json!("keyValuePairs,languages"), "keyValuePairs,languages")] + #[case(json!("keyValuePairs, languages"), "keyValuePairs,languages")] + fn feature_mapping_matches_python(#[case] input: Value, #[case] expected: &str) { + assert_eq!( + map(json!({"features": input})).unwrap().features.as_deref(), + Some(expected) + ); + } + + #[rstest] + #[case(json!("keyValuePairs&pages=9"))] + #[case(json!("key value pairs"))] + #[case(json!(""))] + #[case(json!([1, 2]))] + #[case(json!([["keyValuePairs"]]))] + #[case(json!({"feature":"keyValuePairs"}))] + #[case(json!(5))] + fn invalid_feature_mapping_matches_python(#[case] input: Value) { + assert!(map(json!({"features": input})).is_err()); + } + + #[test] + fn empty_feature_list_is_omitted() { + assert_eq!(map(json!({"features": []})).unwrap().features, None); + } #[tokio::test] async fn request_endpoint_cannot_receive_environment_key() {