mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-13 23:11:40 +00:00
feat(ocr): add Reducto legacy and v3 adapters (#40535)
* feat(ocr): add Reducto adapters * fix(ocr): decline missing Reducto credentials * fix(ocr): map Reducto credentials in gateway errors * test(ocr): keep Reducto coverage at SDK boundary * test(ocr): remove stale gateway Reducto cases * fix(ocr): stop retaining Reducto responses by default * refactor(ocr): preserve Reducto extra params * refactor(ocr): adopt request preparation contract * fix(ocr): preserve provider model passthrough * fix(ocr): reject unknown Reducto models * fix(ocr): preserve Reducto provider options
This commit is contained in:
parent
b544f2244b
commit
0dd5e6e289
31 changed files with 902 additions and 953 deletions
|
|
@ -273,7 +273,8 @@ fn core_error_kind(error: &Error) -> &'static str {
|
|||
| Error::MissingApiKey { .. }
|
||||
| Error::MissingAzureAiCredentials
|
||||
| Error::MissingAzureAiCredentialsOrAdToken
|
||||
| Error::MissingAzureDocumentIntelligenceCredentials => "AuthError",
|
||||
| Error::MissingAzureDocumentIntelligenceCredentials
|
||||
| Error::MissingReductoApiKey => "AuthError",
|
||||
Error::InvalidProvider(_) => "InvalidProvider",
|
||||
Error::InvalidRequest(_) => "InvalidRequest",
|
||||
Error::InvalidType { .. } => "InvalidType",
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ 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,
|
||||
|
|
@ -40,7 +39,6 @@ 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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,11 @@
|
|||
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, truncate_error_body};
|
||||
use super::common_utils::{convert_document_url_to_data_uri, string_headers};
|
||||
use super::types::{PreparedOcrRequest, ProviderOcrRequest};
|
||||
use crate::client::http_client;
|
||||
use crate::integrations::custom_guardrail::{
|
||||
CustomGuardrailRunner, GuardrailContext, GuardrailError, GuardrailRequest,
|
||||
};
|
||||
|
|
@ -93,19 +89,7 @@ impl OcrLifecycleHooks {
|
|||
)?;
|
||||
let model = request.model.clone();
|
||||
let custom_llm_provider = request.custom_llm_provider.clone();
|
||||
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() {
|
||||
let document = if config.requires_data_uri_document() {
|
||||
convert_document_url_to_data_uri(request.document).await?
|
||||
} else {
|
||||
request.document
|
||||
|
|
@ -114,12 +98,9 @@ impl OcrLifecycleHooks {
|
|||
let body = config
|
||||
.transform_ocr_request(&request.model, document, optional_params.clone())?
|
||||
.data;
|
||||
let body = if is_reducto {
|
||||
body
|
||||
} else {
|
||||
self.run_during_call_guardrails(&model, &custom_llm_provider, &url, body)
|
||||
.await?
|
||||
};
|
||||
let body = self
|
||||
.run_during_call_guardrails(&model, &custom_llm_provider, &url, body)
|
||||
.await?;
|
||||
Ok(ProviderOcrRequest {
|
||||
model,
|
||||
config,
|
||||
|
|
@ -186,63 +167,6 @@ impl OcrLifecycleHooks {
|
|||
}
|
||||
}
|
||||
|
||||
async fn upload_reducto_document(
|
||||
document: &Value,
|
||||
api_base: Option<&str>,
|
||||
timeout: Option<std::time::Duration>,
|
||||
upstream_headers: &[(String, String)],
|
||||
) -> Result<Value, Error> {
|
||||
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<PreparedOcrRequest, PreparedOcrRequest, Value> for OcrLifecycleHooks {
|
||||
type PreCallFuture<'a> = OcrFuture<'a, PreparedOcrRequest>;
|
||||
type DuringCallFuture<'a> = OcrFuture<'a, PreparedOcrRequest>;
|
||||
|
|
@ -390,7 +314,8 @@ fn core_error_kind(error: &Error) -> &'static str {
|
|||
| Error::MissingApiKey { .. }
|
||||
| Error::MissingAzureAiCredentials
|
||||
| Error::MissingAzureAiCredentialsOrAdToken
|
||||
| Error::MissingAzureDocumentIntelligenceCredentials => "AuthError",
|
||||
| Error::MissingAzureDocumentIntelligenceCredentials
|
||||
| Error::MissingReductoApiKey => "AuthError",
|
||||
Error::InvalidProvider(_) => "InvalidProvider",
|
||||
Error::InvalidRequest(_) => "InvalidRequest",
|
||||
Error::InvalidType { .. } => "InvalidType",
|
||||
|
|
|
|||
|
|
@ -25,162 +25,16 @@ pub async fn ocr(request: OcrRequest<'_>) -> Result<Value, Error> {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::{Map, json};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
|
||||
use super::{OcrRequest, ocr};
|
||||
use crate::integrations::types::RequestMetadata;
|
||||
use litellm_core::ocr::wire::is_supported_request;
|
||||
|
||||
#[test]
|
||||
fn core_activation_includes_azure_document_intelligence() {
|
||||
fn core_activation_includes_migrated_providers() {
|
||||
assert!(is_supported_request("model", Some("mistral")));
|
||||
assert!(is_supported_request("pixtral-12b", Some("azure_ai")));
|
||||
assert!(is_supported_request(
|
||||
"doc-intelligence/prebuilt-layout",
|
||||
Some("azure_ai")
|
||||
));
|
||||
assert!(!is_supported_request("parse-v3", Some("reducto")));
|
||||
}
|
||||
|
||||
async fn read_http_request(socket: &mut TcpStream) -> String {
|
||||
let mut request = Vec::new();
|
||||
let mut buffer = [0_u8; 1024];
|
||||
let header_end = loop {
|
||||
let n = socket.read(&mut buffer).await.expect("reads request");
|
||||
if n == 0 {
|
||||
break request.len();
|
||||
}
|
||||
request.extend_from_slice(&buffer[..n]);
|
||||
if let Some(position) = request.windows(4).position(|window| window == b"\r\n\r\n") {
|
||||
break position + 4;
|
||||
}
|
||||
};
|
||||
let headers = String::from_utf8_lossy(&request[..header_end]);
|
||||
let content_length = headers
|
||||
.lines()
|
||||
.find_map(|line| {
|
||||
let (name, value) = line.split_once(':')?;
|
||||
name.eq_ignore_ascii_case("content-length")
|
||||
.then(|| value.trim().parse::<usize>().ok())
|
||||
.flatten()
|
||||
})
|
||||
.unwrap_or(0);
|
||||
while request.len().saturating_sub(header_end) < content_length {
|
||||
let n = socket.read(&mut buffer).await.expect("reads body");
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
request.extend_from_slice(&buffer[..n]);
|
||||
}
|
||||
String::from_utf8(request).expect("request is utf8")
|
||||
}
|
||||
|
||||
fn base_ocr_request(model: &str) -> OcrRequest<'_> {
|
||||
OcrRequest {
|
||||
model,
|
||||
document: json!({
|
||||
"type": "document_url",
|
||||
"document_url": "https://example.com/doc.pdf"
|
||||
}),
|
||||
api_key: Some("sk-test"),
|
||||
api_base: None,
|
||||
custom_llm_provider: None,
|
||||
extra_headers: None,
|
||||
optional_params: Map::new(),
|
||||
timeout: None,
|
||||
callbacks: Vec::new(),
|
||||
guardrails: Vec::new(),
|
||||
request_metadata: RequestMetadata::default(),
|
||||
litellm_call_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reducto_file_upload_then_parse_maps_response() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("test listener binds");
|
||||
let address = listener.local_addr().expect("listener has local address");
|
||||
let server = tokio::spawn(async move {
|
||||
let (mut upload_socket, _) = listener.accept().await.expect("accepts upload request");
|
||||
let upload_request = read_http_request(&mut upload_socket).await;
|
||||
let upload_body = r#"{"file_id":"reducto://uploaded.pdf"}"#;
|
||||
let upload_response = format!(
|
||||
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
|
||||
upload_body.len(),
|
||||
upload_body
|
||||
);
|
||||
upload_socket
|
||||
.write_all(upload_response.as_bytes())
|
||||
.await
|
||||
.expect("writes upload response");
|
||||
|
||||
let (mut parse_socket, _) = listener.accept().await.expect("accepts parse request");
|
||||
let parse_request = read_http_request(&mut parse_socket).await;
|
||||
let parse_body = r#"{"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"}]}]}}"#;
|
||||
let parse_response = format!(
|
||||
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
|
||||
parse_body.len(),
|
||||
parse_body
|
||||
);
|
||||
parse_socket
|
||||
.write_all(parse_response.as_bytes())
|
||||
.await
|
||||
.expect("writes parse response");
|
||||
(upload_request, parse_request)
|
||||
});
|
||||
let api_base = format!("http://{address}");
|
||||
let mut request = base_ocr_request("reducto/parse-v3");
|
||||
request.api_base = Some(&api_base);
|
||||
request.api_key = None;
|
||||
request.extra_headers = Some(Map::from_iter([
|
||||
("Authorization".to_string(), json!("Bearer test-key")),
|
||||
("x-trace-id".to_string(), json!("trace-1")),
|
||||
]));
|
||||
request.document = json!({
|
||||
"type": "document_url",
|
||||
"document_url": "data:application/pdf;base64,JVBERi0xLjQ="
|
||||
});
|
||||
request.optional_params = Map::from_iter([
|
||||
(
|
||||
"formatting".to_string(),
|
||||
json!({"table_output_format": "html"}),
|
||||
),
|
||||
("retrieval".to_string(), json!({"chunk_mode": "section"})),
|
||||
("settings".to_string(), json!({"ocr_system": "standard"})),
|
||||
]);
|
||||
|
||||
let response = ocr(request).await.expect("Reducto OCR succeeds");
|
||||
|
||||
assert_eq!(response["pages"].as_array().map(Vec::len), Some(3));
|
||||
assert_eq!(
|
||||
response["pages"][0]["markdown"],
|
||||
"Page 1 block A\n\nPage 1 block B"
|
||||
);
|
||||
assert_eq!(response["pages"][1]["markdown"], "Page 2 block A");
|
||||
assert_eq!(response["pages"][2]["markdown"], "Page 3 block A");
|
||||
assert_eq!(response["usage_info"]["pages_processed"], 3);
|
||||
assert_eq!(response["usage_info"]["credits"], 3);
|
||||
assert_eq!(response["provider_native_response"]["job_id"], "job_123");
|
||||
let (upload_request, parse_request) = server.await.expect("server task completes");
|
||||
assert!(
|
||||
upload_request
|
||||
.to_ascii_lowercase()
|
||||
.contains("authorization: bearer test-key")
|
||||
);
|
||||
assert!(upload_request.contains("application/pdf"));
|
||||
assert!(upload_request.contains("%PDF-1.4"));
|
||||
assert!(upload_request.contains("x-trace-id: trace-1"));
|
||||
assert!(
|
||||
parse_request
|
||||
.to_ascii_lowercase()
|
||||
.contains("authorization: bearer test-key")
|
||||
);
|
||||
assert!(parse_request.contains(r#""input":"reducto://uploaded.pdf""#));
|
||||
assert!(parse_request.contains(r#""table_output_format":"html""#));
|
||||
assert!(parse_request.contains(r#""chunk_mode":"section""#));
|
||||
assert!(parse_request.contains(r#""ocr_system":"standard""#));
|
||||
assert!(is_supported_request("parse-v3", Some("reducto")));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -118,7 +118,8 @@ impl IntoResponse for MessagesRouteError {
|
|||
| Error::MissingApiKey { .. }
|
||||
| Error::MissingAzureAiCredentials
|
||||
| Error::MissingAzureAiCredentialsOrAdToken
|
||||
| Error::MissingAzureDocumentIntelligenceCredentials => (
|
||||
| Error::MissingAzureDocumentIntelligenceCredentials
|
||||
| Error::MissingReductoApiKey => (
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"messages provider request failed".to_string(),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -158,15 +158,6 @@ impl RecordingOcrGuardrail {
|
|||
}
|
||||
}
|
||||
|
||||
fn blocking_during_call() -> Self {
|
||||
Self {
|
||||
hooks: vec![GuardrailEventHook::DuringCall],
|
||||
events: Mutex::new(Vec::new()),
|
||||
block_pre_call: false,
|
||||
block_during_call: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn events(&self) -> Vec<&'static str> {
|
||||
self.events.lock().unwrap().clone()
|
||||
}
|
||||
|
|
@ -216,26 +207,6 @@ impl CustomGuardrail for RecordingOcrGuardrail {
|
|||
}
|
||||
}
|
||||
|
||||
fn base_ocr_request(model: &str) -> OcrRequest<'_> {
|
||||
OcrRequest {
|
||||
model,
|
||||
document: json!({
|
||||
"type": "document_url",
|
||||
"document_url": "https://example.com/doc.pdf"
|
||||
}),
|
||||
api_key: Some("sk-test"),
|
||||
api_base: None,
|
||||
custom_llm_provider: None,
|
||||
extra_headers: None,
|
||||
optional_params: Map::new(),
|
||||
timeout: None,
|
||||
callbacks: Vec::new(),
|
||||
guardrails: Vec::new(),
|
||||
request_metadata: RequestMetadata::default(),
|
||||
litellm_call_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn azure_mistral_uses_prepared_authorization_through_gateway() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
|
|
@ -287,66 +258,6 @@ async fn azure_mistral_uses_prepared_authorization_through_gateway() {
|
|||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reducto_during_call_guardrail_blocks_before_upload() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("test listener binds");
|
||||
let address = listener.local_addr().expect("listener has local address");
|
||||
let api_base = format!("http://{address}");
|
||||
let guardrail = Arc::new(RecordingOcrGuardrail::blocking_during_call());
|
||||
let mut request = base_ocr_request("reducto/parse-v3");
|
||||
request.api_base = Some(&api_base);
|
||||
request.document = json!({
|
||||
"type": "document_url",
|
||||
"document_url": "data:application/pdf;base64,JVBERi0xLjQ="
|
||||
});
|
||||
request.guardrails = vec![guardrail.clone()];
|
||||
|
||||
let error = ocr(request).await.expect_err("guardrail blocks upload");
|
||||
|
||||
assert!(matches!(error, Error::InvalidRequest(_)));
|
||||
assert_eq!(guardrail.events(), vec!["async_moderation_hook"]);
|
||||
let accepted = tokio::time::timeout(Duration::from_millis(100), listener.accept()).await;
|
||||
assert!(accepted.is_err(), "upload socket should not be touched");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reducto_upload_error_body_is_truncated() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("test listener binds");
|
||||
let address = listener.local_addr().expect("listener has local address");
|
||||
let server = tokio::spawn(async move {
|
||||
let (mut socket, _) = listener.accept().await.expect("accepts upload request");
|
||||
let _request = read_http_request(&mut socket).await;
|
||||
let body = "x".repeat(300);
|
||||
let response = format!(
|
||||
"HTTP/1.1 500 Internal Server Error\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
|
||||
body.len(),
|
||||
body
|
||||
);
|
||||
socket
|
||||
.write_all(response.as_bytes())
|
||||
.await
|
||||
.expect("writes upload response");
|
||||
});
|
||||
let api_base = format!("http://{address}");
|
||||
let mut request = base_ocr_request("reducto/parse-v3");
|
||||
request.api_base = Some(&api_base);
|
||||
request.document = json!({
|
||||
"type": "document_url",
|
||||
"document_url": "data:application/pdf;base64,JVBERi0xLjQ="
|
||||
});
|
||||
|
||||
let error = ocr(request).await.expect_err("upload should fail");
|
||||
|
||||
assert!(
|
||||
matches!(error, Error::Http { status: 500, body } if body.chars().count() < 300 && body.ends_with("... (truncated)"))
|
||||
);
|
||||
server.await.expect("server task completes");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ocr_lifecycle_runs_pre_during_and_success_hooks() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
|
|
|
|||
|
|
@ -58,5 +58,8 @@ pub(crate) const AZURE_DI_SUBSCRIPTION_HEADER: &str = "Ocp-Apim-Subscription-Key
|
|||
pub(crate) const AZURE_DI_DEFAULT_DPI: i64 = 96;
|
||||
pub(crate) const AZURE_DI_DEFAULT_WIDTH: f64 = 8.5;
|
||||
pub(crate) const AZURE_DI_DEFAULT_HEIGHT: f64 = 11.0;
|
||||
pub(crate) const REDUCTO_API_BASE: &str = "https://platform.reducto.ai";
|
||||
pub(crate) const REDUCTO_API_KEY_ENV: &str = "REDUCTO_API_KEY";
|
||||
pub(crate) const REDUCTO_ID_PREFIX: &str = "reducto://";
|
||||
pub(crate) const AZURE_AI_OCR_PATH: &str = "/providers/mistral/azure/ocr";
|
||||
pub(crate) const MISTRAL_OCR_API_BASE: &str = "https://api.mistral.ai/v1";
|
||||
|
|
|
|||
|
|
@ -31,6 +31,10 @@ pub enum Error {
|
|||
"invalid authentication configuration: Missing Azure Document Intelligence credentials - set AZURE_DOCUMENT_INTELLIGENCE_API_KEY or configure Entra ID"
|
||||
)]
|
||||
MissingAzureDocumentIntelligenceCredentials,
|
||||
#[error(
|
||||
"Missing REDUCTO_API_KEY - set it in the environment or pass api_key to litellm.ocr()/litellm.aocr()"
|
||||
)]
|
||||
MissingReductoApiKey,
|
||||
#[error("upstream request failed with status {status}: {body}")]
|
||||
Http { status: u16, body: String },
|
||||
#[error("upstream network error: {0}")]
|
||||
|
|
|
|||
|
|
@ -10,9 +10,11 @@ use super::wire::DecodedOcrResponse;
|
|||
|
||||
mod azure;
|
||||
mod mistral;
|
||||
mod reducto;
|
||||
|
||||
pub(crate) use azure::{AzureDocumentIntelligenceAdapter, AzureMistralAdapter};
|
||||
pub(crate) use mistral::MistralAdapter;
|
||||
pub(crate) use reducto::{ReductoLegacyAdapter, ReductoV3Adapter};
|
||||
|
||||
/// Converts a complete LiteLLM OCR call to provider HTTP and normalizes its response.
|
||||
pub(crate) trait OcrAdapter: Send + Sync + Sized + 'static {
|
||||
|
|
@ -66,6 +68,8 @@ macro_rules! for_each_ocr_adapter {
|
|||
Mistral, $crate::ocr::adapters::MistralAdapter, $crate::ocr::adapters::MistralAdapter, Mistral;
|
||||
AzureMistral, $crate::ocr::adapters::AzureMistralAdapter, $crate::ocr::adapters::AzureMistralAdapter, AzureAi;
|
||||
AzureDocumentIntelligence, $crate::ocr::adapters::AzureDocumentIntelligenceAdapter, $crate::ocr::adapters::AzureDocumentIntelligenceAdapter, AzureAi;
|
||||
ReductoLegacy, $crate::ocr::adapters::ReductoLegacyAdapter, $crate::ocr::adapters::ReductoLegacyAdapter, Reducto;
|
||||
ReductoV3, $crate::ocr::adapters::ReductoV3Adapter, $crate::ocr::adapters::ReductoV3Adapter, Reducto;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
|||
45
litellm-rust/crates/core/src/ocr/adapters/reducto/legacy.rs
Normal file
45
litellm-rust/crates/core/src/ocr/adapters/reducto/legacy.rs
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
use super::super::OcrAdapter;
|
||||
use crate::ocr::OcrClient;
|
||||
use crate::ocr::codecs::reducto::{self, ReductoLegacyParams, ReductoResponse};
|
||||
use crate::ocr::error::{OcrError, OcrResponseError};
|
||||
use crate::ocr::prepare::{
|
||||
_prepare_ocr_request, ParsedProviderParams, build_http_request, credential_env,
|
||||
guardrail_document, merge_extra_params,
|
||||
};
|
||||
use crate::ocr::registry::OcrProvider;
|
||||
use crate::ocr::types::{LiteLLMOcrRequest, LiteLLMOcrResponse};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct ReductoLegacyAdapter;
|
||||
|
||||
impl OcrAdapter for ReductoLegacyAdapter {
|
||||
type ProviderResponse = ReductoResponse;
|
||||
const PROVIDER: OcrProvider = OcrProvider::Reducto;
|
||||
|
||||
async fn prepare_request(
|
||||
&self,
|
||||
request: &LiteLLMOcrRequest,
|
||||
client: &OcrClient,
|
||||
) -> Result<reqwest::Request, OcrError> {
|
||||
let ParsedProviderParams {
|
||||
known: params,
|
||||
extra_params,
|
||||
} = _prepare_ocr_request::<ReductoLegacyParams>(request)?;
|
||||
let headers = super::validate_environment(&request.connection, &credential_env)?;
|
||||
let url = super::get_complete_url(request.connection.api_base.as_deref(), "parse")?;
|
||||
let document = guardrail_document(request, &url).await?;
|
||||
let document =
|
||||
super::prepare_document(client, document, &request.connection, &headers).await?;
|
||||
let body = reducto::transform_legacy_ocr_request(&request.model, document, ¶ms)?;
|
||||
let body = merge_extra_params(&body, extra_params)?;
|
||||
build_http_request(client, request, &url, &headers, &body)
|
||||
}
|
||||
|
||||
fn transform_ocr_response(
|
||||
&self,
|
||||
request: &LiteLLMOcrRequest,
|
||||
response: Self::ProviderResponse,
|
||||
) -> Result<LiteLLMOcrResponse, OcrResponseError> {
|
||||
reducto::transform_ocr_response(&request.model, response)
|
||||
}
|
||||
}
|
||||
148
litellm-rust/crates/core/src/ocr/adapters/reducto/mod.rs
Normal file
148
litellm-rust/crates/core/src/ocr/adapters/reducto/mod.rs
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
mod legacy;
|
||||
mod v3;
|
||||
|
||||
use crate::Error;
|
||||
use crate::constants::{REDUCTO_API_BASE, REDUCTO_API_KEY_ENV, REDUCTO_ID_PREFIX};
|
||||
use crate::ocr::document::InlineDocument;
|
||||
use crate::ocr::error::{OcrError, OcrRequestError, OcrResponseError};
|
||||
use crate::ocr::types::{OcrConnection, OcrDocument};
|
||||
use crate::url_utils::ApiUrl;
|
||||
|
||||
pub(crate) use legacy::ReductoLegacyAdapter;
|
||||
pub(crate) use v3::ReductoV3Adapter;
|
||||
|
||||
pub(super) fn get_complete_url(api_base: Option<&str>, path: &str) -> Result<String, OcrError> {
|
||||
let base = api_base
|
||||
.map(str::trim)
|
||||
.filter(|base| !base.is_empty())
|
||||
.unwrap_or(REDUCTO_API_BASE);
|
||||
ApiUrl::parse(base)
|
||||
.and_then(|url| url.complete_path(&[path]))
|
||||
.map(|url| url.into_string())
|
||||
.map_err(|_| {
|
||||
OcrRequestError::RequestField {
|
||||
path: "api_base".into(),
|
||||
}
|
||||
.into()
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn validate_environment(
|
||||
connection: &OcrConnection,
|
||||
env_lookup: &(dyn Fn(&str) -> Option<String> + Sync),
|
||||
) -> Result<Vec<(String, String)>, OcrError> {
|
||||
if crate::http_utils::has_header(&connection.extra_headers, "authorization") {
|
||||
return Ok(connection.extra_headers.clone());
|
||||
}
|
||||
let api_key = connection
|
||||
.api_key
|
||||
.as_deref()
|
||||
.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(Error::MissingReductoApiKey)?;
|
||||
Ok(
|
||||
std::iter::once(("Authorization".into(), format!("Bearer {api_key}")))
|
||||
.chain(connection.extra_headers.clone())
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) async fn prepare_document(
|
||||
client: &crate::ocr::OcrClient,
|
||||
document: OcrDocument,
|
||||
connection: &OcrConnection,
|
||||
headers: &[(String, String)],
|
||||
) -> Result<OcrDocument, OcrError> {
|
||||
if document.source().starts_with(REDUCTO_ID_PREFIX) {
|
||||
if document.source()[REDUCTO_ID_PREFIX.len()..]
|
||||
.trim()
|
||||
.is_empty()
|
||||
{
|
||||
return Err(OcrRequestError::RequestField {
|
||||
path: "document file id".into(),
|
||||
}
|
||||
.into());
|
||||
}
|
||||
return Ok(document);
|
||||
}
|
||||
let inline = InlineDocument::parse(document.source())?.ok_or(OcrRequestError::ReductoSource)?;
|
||||
let mime = inline.mime_type().to_string();
|
||||
let bytes = inline.decode(crate::constants::OCR_INLINE_MAX_BYTES)?;
|
||||
let part = reqwest::multipart::Part::bytes(bytes)
|
||||
.file_name("document")
|
||||
.mime_str(&mime)
|
||||
.map_err(|_| OcrRequestError::InvalidDataUri)?;
|
||||
let builder = client
|
||||
.provider_http()
|
||||
.post(get_complete_url(connection.api_base.as_deref(), "upload")?)
|
||||
.multipart(reqwest::multipart::Form::new().part("file", part))
|
||||
.timeout(connection.timeout);
|
||||
let builder = crate::http_utils::with_headers(
|
||||
builder,
|
||||
headers,
|
||||
crate::http_utils::HeaderPolicy::Except(&["content-type", "content-length"]),
|
||||
);
|
||||
let response = crate::http_utils::http_request(builder)
|
||||
.await
|
||||
.map_err(crate::error::TransportError::from)?;
|
||||
let uploaded = crate::ocr::client::read_json_response::<
|
||||
crate::ocr::codecs::reducto::ReductoUploadResponse,
|
||||
>(response, false)
|
||||
.await?
|
||||
.data;
|
||||
let file_id = uploaded
|
||||
.file_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|id| !id.is_empty());
|
||||
let Some(file_id) = file_id else {
|
||||
return Err(OcrResponseError::ResponseField {
|
||||
path: "file_id".into(),
|
||||
}
|
||||
.into());
|
||||
};
|
||||
Ok(document.with_source(file_id.to_string()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn explicit_key_precedes_environment_key() {
|
||||
let connection = OcrConnection {
|
||||
api_key: Some("passed-key".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let headers = validate_environment(&connection, &|_| Some("env-key".into())).unwrap();
|
||||
assert_eq!(headers[0].1, "Bearer passed-key");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blank_explicit_key_uses_environment_key() {
|
||||
let connection = OcrConnection {
|
||||
api_key: Some(" ".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let headers = validate_environment(&connection, &|_| Some(" env-key ".into())).unwrap();
|
||||
assert_eq!(headers[0].1, "Bearer env-key");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_authorization_skips_key_lookup() {
|
||||
let connection = OcrConnection {
|
||||
extra_headers: vec![("authorization".into(), "Bearer existing".into())],
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
validate_environment(&connection, &|_| None).unwrap(),
|
||||
connection.extra_headers
|
||||
);
|
||||
}
|
||||
}
|
||||
45
litellm-rust/crates/core/src/ocr/adapters/reducto/v3.rs
Normal file
45
litellm-rust/crates/core/src/ocr/adapters/reducto/v3.rs
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
use super::super::OcrAdapter;
|
||||
use crate::ocr::OcrClient;
|
||||
use crate::ocr::codecs::reducto::{self, ReductoResponse, ReductoV3Params};
|
||||
use crate::ocr::error::{OcrError, OcrResponseError};
|
||||
use crate::ocr::prepare::{
|
||||
_prepare_ocr_request, ParsedProviderParams, build_http_request, credential_env,
|
||||
guardrail_document, merge_extra_params,
|
||||
};
|
||||
use crate::ocr::registry::OcrProvider;
|
||||
use crate::ocr::types::{LiteLLMOcrRequest, LiteLLMOcrResponse};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct ReductoV3Adapter;
|
||||
|
||||
impl OcrAdapter for ReductoV3Adapter {
|
||||
type ProviderResponse = ReductoResponse;
|
||||
const PROVIDER: OcrProvider = OcrProvider::Reducto;
|
||||
|
||||
async fn prepare_request(
|
||||
&self,
|
||||
request: &LiteLLMOcrRequest,
|
||||
client: &OcrClient,
|
||||
) -> Result<reqwest::Request, OcrError> {
|
||||
let ParsedProviderParams {
|
||||
known: params,
|
||||
extra_params,
|
||||
} = _prepare_ocr_request::<ReductoV3Params>(request)?;
|
||||
let headers = super::validate_environment(&request.connection, &credential_env)?;
|
||||
let url = super::get_complete_url(request.connection.api_base.as_deref(), "parse")?;
|
||||
let document = guardrail_document(request, &url).await?;
|
||||
let document =
|
||||
super::prepare_document(client, document, &request.connection, &headers).await?;
|
||||
let body = reducto::transform_v3_ocr_request(&request.model, document, ¶ms)?;
|
||||
let body = merge_extra_params(&body, extra_params)?;
|
||||
build_http_request(client, request, &url, &headers, &body)
|
||||
}
|
||||
|
||||
fn transform_ocr_response(
|
||||
&self,
|
||||
request: &LiteLLMOcrRequest,
|
||||
response: Self::ProviderResponse,
|
||||
) -> Result<LiteLLMOcrResponse, OcrResponseError> {
|
||||
reducto::transform_ocr_response(&request.model, response)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,2 +1,3 @@
|
|||
pub(crate) mod document_intelligence;
|
||||
pub(crate) mod mistral;
|
||||
pub(crate) mod reducto;
|
||||
|
|
|
|||
9
litellm-rust/crates/core/src/ocr/codecs/reducto/mod.rs
Normal file
9
litellm-rust/crates/core/src/ocr/codecs/reducto/mod.rs
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
mod transformation;
|
||||
mod types;
|
||||
|
||||
pub(crate) use transformation::{
|
||||
transform_legacy_ocr_request, transform_ocr_response, transform_v3_ocr_request,
|
||||
};
|
||||
pub(crate) use types::{
|
||||
ReductoLegacyParams, ReductoResponse, ReductoUploadResponse, ReductoV3Params,
|
||||
};
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
use std::collections::BTreeMap;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::types::*;
|
||||
use crate::ocr::error::{OcrRequestError, OcrResponseError};
|
||||
use crate::ocr::types::{LiteLLMOcrResponse, OcrDocument};
|
||||
|
||||
#[tracing::instrument(
|
||||
name = "transform_ocr_request",
|
||||
target = "litellm::function_trace",
|
||||
level = "trace",
|
||||
skip_all
|
||||
)]
|
||||
pub(crate) fn transform_v3_ocr_request(
|
||||
_model: &str,
|
||||
document: OcrDocument,
|
||||
params: &ReductoV3Params,
|
||||
) -> Result<ReductoV3Request, OcrRequestError> {
|
||||
Ok(ReductoV3Request {
|
||||
input: document.source().to_string(),
|
||||
params: params.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
#[tracing::instrument(
|
||||
name = "transform_ocr_request",
|
||||
target = "litellm::function_trace",
|
||||
level = "trace",
|
||||
skip_all
|
||||
)]
|
||||
pub(crate) fn transform_legacy_ocr_request(
|
||||
_model: &str,
|
||||
document: OcrDocument,
|
||||
params: &ReductoLegacyParams,
|
||||
) -> Result<ReductoLegacyRequest, OcrRequestError> {
|
||||
Ok(ReductoLegacyRequest {
|
||||
document_url: document.source().to_string(),
|
||||
options: params.enhance.as_ref().map(|_| params.clone()),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn transform_ocr_response(
|
||||
model: &str,
|
||||
response: ReductoResponse,
|
||||
) -> Result<LiteLLMOcrResponse, OcrResponseError> {
|
||||
let result = match response.result {
|
||||
Some(result) => result.unwrap_or_default(),
|
||||
None => ReductoResult {
|
||||
chunks: response.chunks,
|
||||
},
|
||||
};
|
||||
let usage = response.usage.unwrap_or_default();
|
||||
Ok(LiteLLMOcrResponse {
|
||||
pages: build_pages(result.chunks.unwrap_or_default()),
|
||||
model: model.to_string(),
|
||||
document_annotation: None,
|
||||
usage_info: Some(json!({
|
||||
"pages_processed": usage.num_pages,
|
||||
"credits": usage.credits,
|
||||
})),
|
||||
object: "ocr".to_string(),
|
||||
extra_fields: serde_json::Map::new(),
|
||||
provider_native_response: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn build_pages(chunks: Vec<ReductoChunk>) -> Vec<Value> {
|
||||
let blocks_by_page = chunks
|
||||
.iter()
|
||||
.flat_map(|chunk| chunk.blocks.iter().flatten())
|
||||
.filter_map(|block| block.bbox.as_ref()?.page.map(|page| (page, block)))
|
||||
.fold(
|
||||
BTreeMap::<i64, Vec<&ReductoBlock>>::new(),
|
||||
|mut pages, (page, block)| {
|
||||
pages.entry(page).or_default().push(block);
|
||||
pages
|
||||
},
|
||||
);
|
||||
if blocks_by_page.is_empty() {
|
||||
let markdown = join_content(chunks.iter().map(|chunk| chunk.content.as_deref()));
|
||||
return if markdown.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
vec![page(0, markdown, None)]
|
||||
};
|
||||
}
|
||||
blocks_by_page
|
||||
.into_iter()
|
||||
.map(|(index, blocks)| {
|
||||
let markdown = join_content(blocks.iter().map(|block| block.content.as_deref()));
|
||||
page(
|
||||
index.saturating_sub(1).max(0),
|
||||
markdown,
|
||||
Some(json!(blocks)),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn join_content<'a>(content: impl Iterator<Item = Option<&'a str>>) -> String {
|
||||
content
|
||||
.flatten()
|
||||
.filter(|text| !text.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n\n")
|
||||
}
|
||||
|
||||
fn page(index: i64, markdown: String, blocks: Option<Value>) -> Value {
|
||||
let mut result = json!({"index":index,"markdown":markdown,"images":null});
|
||||
if let (Value::Object(fields), Some(blocks)) = (&mut result, blocks) {
|
||||
fields.insert("blocks".into(), blocks);
|
||||
}
|
||||
result
|
||||
}
|
||||
128
litellm-rust/crates/core/src/ocr/codecs/reducto/types.rs
Normal file
128
litellm-rust/crates/core/src/ocr/codecs/reducto/types.rs
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
pub(crate) struct ReductoV3Params {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub formatting: Option<Map<String, Value>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub retrieval: Option<Map<String, Value>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub settings: Option<Map<String, Value>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
pub(crate) struct ReductoLegacyParams {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub enhance: Option<Map<String, Value>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub(crate) struct ReductoV3Request {
|
||||
pub input: String,
|
||||
#[serde(flatten)]
|
||||
pub params: ReductoV3Params,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub(crate) struct ReductoLegacyRequest {
|
||||
pub document_url: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub options: Option<ReductoLegacyParams>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(crate) struct ReductoUploadResponse {
|
||||
pub file_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub(crate) struct ReductoResponse {
|
||||
#[serde(default, deserialize_with = "present_nullable")]
|
||||
pub result: Option<Option<ReductoResult>>,
|
||||
pub usage: Option<ReductoUsage>,
|
||||
#[serde(default)]
|
||||
pub chunks: Option<Vec<ReductoChunk>>,
|
||||
}
|
||||
|
||||
fn present_nullable<'de, D: Deserializer<'de>, T: Deserialize<'de>>(
|
||||
deserializer: D,
|
||||
) -> Result<Option<Option<T>>, D::Error> {
|
||||
Option::<T>::deserialize(deserializer).map(Some)
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
pub(crate) struct ReductoResult {
|
||||
pub chunks: Option<Vec<ReductoChunk>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
pub(crate) struct ReductoUsage {
|
||||
#[serde(default, deserialize_with = "optional_i64")]
|
||||
pub num_pages: Option<i64>,
|
||||
#[serde(default, deserialize_with = "optional_f64")]
|
||||
pub credits: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub(crate) struct ReductoChunk {
|
||||
pub content: Option<String>,
|
||||
pub blocks: Option<Vec<ReductoBlock>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub(crate) struct ReductoBlock {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub content: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub bbox: Option<ReductoBoundingBox>,
|
||||
#[serde(flatten)]
|
||||
pub extra_fields: Map<String, Value>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub(crate) struct ReductoBoundingBox {
|
||||
#[serde(default, deserialize_with = "optional_i64")]
|
||||
pub page: Option<i64>,
|
||||
#[serde(flatten)]
|
||||
pub extra_fields: Map<String, Value>,
|
||||
}
|
||||
|
||||
fn optional_i64<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Option<i64>, D::Error> {
|
||||
match Option::<Value>::deserialize(deserializer)? {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(Value::Number(number)) => number
|
||||
.as_i64()
|
||||
.or_else(|| number.as_f64().and_then(checked_truncated_i64))
|
||||
.map(Some)
|
||||
.ok_or_else(|| serde::de::Error::custom("expected an integer")),
|
||||
Some(Value::String(value)) => value
|
||||
.trim()
|
||||
.parse::<i64>()
|
||||
.map(Some)
|
||||
.map_err(|_| serde::de::Error::custom("expected an integer")),
|
||||
Some(Value::Bool(value)) => Ok(Some(i64::from(value))),
|
||||
Some(_) => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn optional_f64<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Option<f64>, D::Error> {
|
||||
match Option::<Value>::deserialize(deserializer)? {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(Value::Number(number)) => number
|
||||
.as_f64()
|
||||
.map(Some)
|
||||
.ok_or_else(|| serde::de::Error::custom("expected a number")),
|
||||
Some(Value::String(value)) => value
|
||||
.trim()
|
||||
.parse::<f64>()
|
||||
.map(Some)
|
||||
.map_err(|_| serde::de::Error::custom("expected a number")),
|
||||
Some(_) => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn checked_truncated_i64(value: f64) -> Option<i64> {
|
||||
(value.is_finite() && value >= i64::MIN as f64 && value <= i64::MAX as f64)
|
||||
.then(|| value.trunc() as i64)
|
||||
}
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
use base64::{Engine, engine::general_purpose::STANDARD};
|
||||
#[cfg(test)]
|
||||
use data_url::mime::Mime;
|
||||
use data_url::{DataUrl, DataUrlError, forgiving_base64::DecodeError};
|
||||
use reqwest::Url;
|
||||
|
|
@ -21,7 +20,6 @@ impl<'a> InlineDocument<'a> {
|
|||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn mime_type(&self) -> &Mime {
|
||||
self.0.mime_type()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ pub enum OcrRequestError {
|
|||
MissingField(&'static str),
|
||||
#[error("invalid OCR document data URI")]
|
||||
InvalidDataUri,
|
||||
#[error("Reducto requires a reducto:// id or a data URI")]
|
||||
ReductoSource,
|
||||
#[error("inline OCR document exceeds the size limit")]
|
||||
InlineDocumentTooLarge,
|
||||
#[error("OCR document URL is blocked by network policy")]
|
||||
|
|
|
|||
|
|
@ -21,6 +21,9 @@ mod azure_ai_tests;
|
|||
#[path = "../../tests/azure_document_intelligence_ocr.rs"]
|
||||
mod azure_document_intelligence_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "../../tests/reducto_ocr.rs"]
|
||||
mod reducto_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "../../tests/ocr/support.rs"]
|
||||
pub(crate) mod test_support;
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ use serde_json::{Map, Value};
|
|||
use super::OcrClient;
|
||||
use super::error::{OcrError, OcrRequestError};
|
||||
use super::hooks::OcrDuringCallRequest;
|
||||
use super::types::LiteLLMOcrRequest;
|
||||
use super::types::{LiteLLMOcrRequest, OcrDocument};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct ParsedProviderParams<T> {
|
||||
|
|
@ -24,6 +24,39 @@ pub(crate) fn _prepare_ocr_request<T: DeserializeOwned>(
|
|||
)
|
||||
}
|
||||
|
||||
pub(crate) fn merge_extra_params<B: Serialize>(
|
||||
body: &B,
|
||||
extra_params: Map<String, Value>,
|
||||
) -> Result<Value, OcrRequestError> {
|
||||
let Value::Object(fields) =
|
||||
serde_json::to_value(body).map_err(|_| OcrRequestError::RequestField {
|
||||
path: "body".into(),
|
||||
})?
|
||||
else {
|
||||
return Err(OcrRequestError::RequestField {
|
||||
path: "body".into(),
|
||||
});
|
||||
};
|
||||
let extra_body = extra_params
|
||||
.get("extra_body")
|
||||
.and_then(Value::as_object)
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.collect::<Map<String, Value>>();
|
||||
Ok(Value::Object(
|
||||
fields
|
||||
.into_iter()
|
||||
.chain(
|
||||
extra_params
|
||||
.into_iter()
|
||||
.filter(|(name, _)| name != "extra_body"),
|
||||
)
|
||||
.chain(extra_body)
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) async fn transform_request_body<B>(
|
||||
client: &OcrClient,
|
||||
request: &LiteLLMOcrRequest,
|
||||
|
|
@ -77,6 +110,29 @@ pub(crate) fn build_http_request<B: Serialize>(
|
|||
.map_err(OcrError::from)
|
||||
}
|
||||
|
||||
pub(crate) async fn guardrail_document(
|
||||
request: &LiteLLMOcrRequest,
|
||||
url: &str,
|
||||
) -> Result<OcrDocument, OcrError> {
|
||||
if !request.hooks.has_guardrails() {
|
||||
return Ok(request.document.clone());
|
||||
}
|
||||
let changed = request
|
||||
.hooks
|
||||
.during_call(OcrDuringCallRequest {
|
||||
model: request.model.clone(),
|
||||
custom_llm_provider: request.adapter.provider().as_str().into(),
|
||||
url: url.into(),
|
||||
body: serde_json::to_value(&request.document).map_err(|_| {
|
||||
OcrRequestError::RequestField {
|
||||
path: "document".into(),
|
||||
}
|
||||
})?,
|
||||
})
|
||||
.await?;
|
||||
super::wire::decode_request_value(changed.body, "guardrail.document").map_err(OcrError::from)
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct OcrWireBody<B> {
|
||||
#[serde(flatten)]
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ super::adapters::for_each_ocr_adapter!(define_adapter_types);
|
|||
pub(crate) enum OcrProvider {
|
||||
Mistral,
|
||||
AzureAi,
|
||||
Reducto,
|
||||
}
|
||||
|
||||
impl OcrProvider {
|
||||
|
|
@ -32,6 +33,7 @@ impl OcrProvider {
|
|||
match self {
|
||||
Self::Mistral => "mistral",
|
||||
Self::AzureAi => "azure_ai",
|
||||
Self::Reducto => "reducto",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -48,19 +50,72 @@ pub(crate) fn resolve_wire_adapter(
|
|||
let typed_provider = match provider.custom_llm_provider {
|
||||
"mistral" => OcrProvider::Mistral,
|
||||
"azure_ai" => OcrProvider::AzureAi,
|
||||
"reducto" => OcrProvider::Reducto,
|
||||
value => return Err(Error::InvalidProvider(value.to_string())),
|
||||
};
|
||||
match typed_provider {
|
||||
OcrProvider::Mistral => Ok((provider.model.to_string(), OcrAdapterKind::Mistral)),
|
||||
OcrProvider::AzureAi if is_document_intelligence_model(provider.model) => Ok((
|
||||
provider.model.to_string(),
|
||||
OcrAdapterKind::AzureDocumentIntelligence,
|
||||
)),
|
||||
OcrProvider::AzureAi => Ok((provider.model.to_string(), OcrAdapterKind::AzureMistral)),
|
||||
}
|
||||
let adapter = match typed_provider {
|
||||
OcrProvider::Mistral => OcrAdapterKind::Mistral,
|
||||
OcrProvider::AzureAi if is_document_intelligence_model(provider.model) => {
|
||||
OcrAdapterKind::AzureDocumentIntelligence
|
||||
}
|
||||
OcrProvider::AzureAi => OcrAdapterKind::AzureMistral,
|
||||
OcrProvider::Reducto if provider.model.eq_ignore_ascii_case("parse-legacy") => {
|
||||
OcrAdapterKind::ReductoLegacy
|
||||
}
|
||||
OcrProvider::Reducto if provider.model.eq_ignore_ascii_case("parse-v3") => {
|
||||
OcrAdapterKind::ReductoV3
|
||||
}
|
||||
OcrProvider::Reducto => {
|
||||
return Err(Error::InvalidRequest(format!(
|
||||
"unsupported Reducto OCR model: {}",
|
||||
provider.model
|
||||
)));
|
||||
}
|
||||
};
|
||||
Ok((provider.model.to_string(), adapter))
|
||||
}
|
||||
|
||||
fn is_document_intelligence_model(model: &str) -> bool {
|
||||
let model = model.to_ascii_lowercase();
|
||||
model.contains("doc-intelligence") || model.contains("documentintelligence")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn provider_models_are_preserved_without_a_local_allowlist() {
|
||||
let cases = [
|
||||
("mistral/future-ocr-model", OcrAdapterKind::Mistral),
|
||||
("azure_ai/future-ocr-model", OcrAdapterKind::AzureMistral),
|
||||
];
|
||||
|
||||
for (qualified_model, expected_adapter) in cases {
|
||||
let expected_model = qualified_model.split_once('/').unwrap().1;
|
||||
let (model, adapter) = resolve_wire_adapter(qualified_model, None).unwrap();
|
||||
assert_eq!(model, expected_model);
|
||||
assert_eq!(adapter, expected_adapter);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_reducto_models_are_rejected() {
|
||||
assert!(matches!(
|
||||
resolve_wire_adapter("reducto/future-parse-model", None),
|
||||
Err(Error::InvalidRequest(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_protocol_models_still_select_specialized_adapters() {
|
||||
let (model, adapter) = resolve_wire_adapter("reducto/parse-legacy", None).unwrap();
|
||||
assert_eq!(model, "parse-legacy");
|
||||
assert_eq!(adapter, OcrAdapterKind::ReductoLegacy);
|
||||
|
||||
let (model, adapter) =
|
||||
resolve_wire_adapter("azure_ai/doc-intelligence/prebuilt-layout", None).unwrap();
|
||||
assert_eq!(model, "doc-intelligence/prebuilt-layout");
|
||||
assert_eq!(adapter, OcrAdapterKind::AzureDocumentIntelligence);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,5 +4,4 @@ pub mod azure_ai;
|
|||
pub mod bedrock;
|
||||
pub mod mistral;
|
||||
pub mod openai;
|
||||
pub mod reducto;
|
||||
pub mod vertex_ai;
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
pub mod ocr;
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
pub mod transformation;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
|
@ -1,202 +0,0 @@
|
|||
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())]
|
||||
);
|
||||
}
|
||||
|
|
@ -1,407 +0,0 @@
|
|||
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::{LiteLLMOcrResponse, OcrRequestData};
|
||||
|
||||
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<u8>, mime_type: String },
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct ReductoUploadRequest {
|
||||
pub url: String,
|
||||
pub authorization: String,
|
||||
pub file_name: &'static str,
|
||||
pub bytes: Vec<u8>,
|
||||
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<String>,
|
||||
) -> Result<String, Error> {
|
||||
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<ReductoDocumentSource, Error> {
|
||||
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<ReductoDocumentSource, Error> {
|
||||
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<ReductoUploadRequest> {
|
||||
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<String, Value>,
|
||||
) -> 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<String, Value>,
|
||||
) -> 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<String, Error> {
|
||||
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<String, Value>) -> Option<i64> {
|
||||
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<String, Value>) -> &[Value] {
|
||||
result
|
||||
.get("chunks")
|
||||
.and_then(Value::as_array)
|
||||
.map(Vec::as_slice)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn build_pages(result: &Map<String, Value>) -> Vec<Value> {
|
||||
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::<i64, Vec<Value>>::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::<Vec<_>>()
|
||||
.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::<Vec<_>>()
|
||||
.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<LiteLLMOcrResponse, Error> {
|
||||
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(LiteLLMOcrResponse {
|
||||
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<String, Value>,
|
||||
) -> Result<OcrRequestData, Error> {
|
||||
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<LiteLLMOcrResponse, Error> {
|
||||
transform_reducto_response(model, response_json)
|
||||
}
|
||||
|
||||
fn complete_url(
|
||||
&self,
|
||||
api_base: Option<&str>,
|
||||
_model: &str,
|
||||
_optional_params: &Map<String, Value>,
|
||||
_env_lookup: &dyn Fn(&str) -> Option<String>,
|
||||
) -> Result<String, Error> {
|
||||
Ok(parse_url(api_base))
|
||||
}
|
||||
|
||||
fn resolve_api_key(
|
||||
&self,
|
||||
api_key: Option<&str>,
|
||||
env_lookup: &dyn Fn(&str) -> Option<String>,
|
||||
) -> Result<String, Error> {
|
||||
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<String, Value>,
|
||||
) -> Result<OcrRequestData, Error> {
|
||||
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<LiteLLMOcrResponse, Error> {
|
||||
transform_reducto_response(model, response_json)
|
||||
}
|
||||
|
||||
fn complete_url(
|
||||
&self,
|
||||
api_base: Option<&str>,
|
||||
_model: &str,
|
||||
_optional_params: &Map<String, Value>,
|
||||
_env_lookup: &dyn Fn(&str) -> Option<String>,
|
||||
) -> Result<String, Error> {
|
||||
Ok(parse_url(api_base))
|
||||
}
|
||||
|
||||
fn resolve_api_key(
|
||||
&self,
|
||||
api_key: Option<&str>,
|
||||
env_lookup: &dyn Fn(&str) -> Option<String>,
|
||||
) -> Result<String, Error> {
|
||||
resolve_api_key(api_key, env_lookup)
|
||||
}
|
||||
}
|
||||
232
litellm-rust/crates/core/tests/reducto_ocr.rs
Normal file
232
litellm-rust/crates/core/tests/reducto_ocr.rs
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use rstest::rstest;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::hooks::{OcrDuringCallRequest, OcrHookFuture, OcrHooks};
|
||||
use super::test_support::{MockResponse, mock_server, perform_ocr, wire_request};
|
||||
|
||||
fn request_body(request: &str) -> Value {
|
||||
serde_json::from_str(request.split_once("\r\n\r\n").unwrap().1).unwrap()
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case(
|
||||
"reducto/parse-v3",
|
||||
json!({
|
||||
"formatting":{"table_output_format":"html"},
|
||||
"retrieval":{"chunk_mode":"section"},
|
||||
"settings":{"ocr_system":"standard"},
|
||||
"future_ocr_option":true,
|
||||
"extra_body":{"provider_option":"value"}
|
||||
}),
|
||||
"reducto://already.pdf",
|
||||
json!({
|
||||
"input":"reducto://already.pdf",
|
||||
"formatting":{"table_output_format":"html"},
|
||||
"retrieval":{"chunk_mode":"section"},
|
||||
"settings":{"ocr_system":"standard"},
|
||||
"future_ocr_option":true,
|
||||
"provider_option":"value"
|
||||
})
|
||||
)]
|
||||
#[case(
|
||||
"reducto/parse-legacy",
|
||||
json!({
|
||||
"enhance":{"agentic":[{"type":"table"}]},
|
||||
"future_ocr_option":true,
|
||||
"extra_body":{"provider_option":"value"}
|
||||
}),
|
||||
"reducto://legacy.pdf",
|
||||
json!({
|
||||
"document_url":"reducto://legacy.pdf",
|
||||
"options":{"enhance":{"agentic":[{"type":"table"}]}},
|
||||
"future_ocr_option":true,
|
||||
"provider_option":"value"
|
||||
})
|
||||
)]
|
||||
#[tokio::test]
|
||||
async fn request_mapping_matches_python(
|
||||
#[case] model: &str,
|
||||
#[case] options: Value,
|
||||
#[case] source: &str,
|
||||
#[case] expected: Value,
|
||||
) {
|
||||
let (base, seen, server) = mock_server(vec![MockResponse::json(json!({
|
||||
"result":{"chunks":[]}
|
||||
}))])
|
||||
.await;
|
||||
let mut request = wire_request(model, &base, options);
|
||||
request.document = request.document.with_source(source.into());
|
||||
|
||||
perform_ocr(request).await.unwrap();
|
||||
server.await.unwrap();
|
||||
let requests = seen.lock().unwrap();
|
||||
assert_eq!(requests.len(), 1);
|
||||
assert!(requests[0].starts_with("POST /parse "));
|
||||
assert_eq!(request_body(&requests[0]), expected);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case("parse-v3")]
|
||||
#[case("parse-legacy")]
|
||||
#[tokio::test]
|
||||
async fn data_uri_upload_preserves_multipart_headers(#[case] model: &str) {
|
||||
let (base, seen, server) = mock_server(vec![
|
||||
MockResponse::json(json!({"file_id":"reducto://uploaded.pdf"})),
|
||||
MockResponse::json(json!({"result":{"chunks":[{"content":"hello"}]}})),
|
||||
])
|
||||
.await;
|
||||
let mut request = wire_request(&format!("reducto/{model}"), &base, json!({}));
|
||||
request.connection.extra_headers = vec![
|
||||
("Content-Type".into(), "application/json".into()),
|
||||
("X-Trace".into(), "upload-test".into()),
|
||||
];
|
||||
|
||||
let response = perform_ocr(request).await.unwrap();
|
||||
server.await.unwrap();
|
||||
assert_eq!(response.pages[0]["markdown"], "hello");
|
||||
let requests = seen.lock().unwrap();
|
||||
assert_eq!(requests.len(), 2);
|
||||
assert!(requests[0].starts_with("POST /upload "));
|
||||
assert!(
|
||||
requests[0]
|
||||
.to_ascii_lowercase()
|
||||
.contains("content-type: multipart/form-data; boundary=")
|
||||
);
|
||||
assert!(requests[0].contains("x-trace: upload-test"));
|
||||
assert!(requests[0].contains("application/pdf"));
|
||||
assert!(requests[0].contains("abc"));
|
||||
assert!(requests[1].starts_with("POST /parse "));
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case(json!({"file_id":""}))]
|
||||
#[case(json!({}))]
|
||||
#[case(json!({"file_id":null}))]
|
||||
#[tokio::test]
|
||||
async fn invalid_upload_ids_stop_before_parse(#[case] response: Value) {
|
||||
let (base, seen, server) = mock_server(vec![MockResponse::json(response)]).await;
|
||||
let error = perform_ocr(wire_request("reducto/parse-v3", &base, json!({})))
|
||||
.await
|
||||
.unwrap_err();
|
||||
server.await.unwrap();
|
||||
assert!(error.to_string().contains("file_id"));
|
||||
assert_eq!(seen.lock().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn upload_failure_stops_before_parse() {
|
||||
let (base, seen, server) = mock_server(vec![MockResponse {
|
||||
status: 503,
|
||||
headers: vec![],
|
||||
body: json!({"error":"unavailable"}),
|
||||
}])
|
||||
.await;
|
||||
assert!(
|
||||
perform_ocr(wire_request("reducto/parse-v3", &base, json!({})))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
server.await.unwrap();
|
||||
assert_eq!(seen.lock().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case("https://example.com/a.pdf")]
|
||||
#[case("reducto://")]
|
||||
#[case("data:application/pdf;base64")]
|
||||
#[case("data:application/pdf;base64,INVALID!")]
|
||||
#[tokio::test]
|
||||
async fn rejects_invalid_document_sources_before_network(#[case] source: &str) {
|
||||
let mut request = wire_request("reducto/parse-v3", "http://127.0.0.1:1", json!({}));
|
||||
request.document = request.document.with_source(source.into());
|
||||
assert!(perform_ocr(request).await.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_normalization_groups_blocks_and_distinguishes_null_result() {
|
||||
use crate::ocr::codecs::reducto::{ReductoResponse, transform_ocr_response};
|
||||
|
||||
let raw = json!({"usage":{"num_pages":"2","credits":"3"},"result":{"chunks":[
|
||||
{"blocks":[{"content":"B","bbox":{"page":2},"kind":"table"}]},
|
||||
{"blocks":[{"content":"A","bbox":{"page":1},"kind":"text"},{"content":"C","bbox":{"page":1}}]}
|
||||
]}});
|
||||
let response: ReductoResponse = serde_json::from_value(raw).unwrap();
|
||||
let normalized = transform_ocr_response("parse-v3", response)
|
||||
.unwrap()
|
||||
.into_json();
|
||||
assert_eq!(normalized["pages"][0]["markdown"], "A\n\nC");
|
||||
assert_eq!(normalized["pages"][1]["markdown"], "B");
|
||||
assert_eq!(normalized["pages"][1]["blocks"][0]["kind"], "table");
|
||||
assert_eq!(normalized["usage_info"]["pages_processed"], 2);
|
||||
assert_eq!(normalized["usage_info"]["credits"], 3.0);
|
||||
|
||||
let missing: ReductoResponse =
|
||||
serde_json::from_value(json!({"chunks":[{"content":"text"}]})).unwrap();
|
||||
let missing = transform_ocr_response("parse-v3", missing).unwrap();
|
||||
assert_eq!(missing.pages[0]["markdown"], "text");
|
||||
let null: ReductoResponse = serde_json::from_value(
|
||||
json!({"result":null,"chunks":[{"content":"ignored"}],"usage":null}),
|
||||
)
|
||||
.unwrap();
|
||||
let null = transform_ocr_response("parse-v3", null).unwrap();
|
||||
assert!(null.pages.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn facade_omits_native_response_by_default_and_preserves_auth_priority() {
|
||||
let raw = json!({"job_id":"job-1","result":{"chunks":[]}});
|
||||
let (base, seen, server) = mock_server(vec![MockResponse::json(raw)]).await;
|
||||
let mut request = wire_request("reducto/parse-v3", &base, json!({}));
|
||||
request.document = request.document.with_source("reducto://ready.pdf".into());
|
||||
request.connection.extra_headers = vec![("authorization".into(), "Bearer existing".into())];
|
||||
|
||||
let response = perform_ocr(request).await.unwrap();
|
||||
server.await.unwrap();
|
||||
assert_eq!(response.provider_native_response, None);
|
||||
assert!(
|
||||
seen.lock().unwrap()[0]
|
||||
.to_ascii_lowercase()
|
||||
.contains("authorization: bearer existing")
|
||||
);
|
||||
}
|
||||
|
||||
struct RewriteDocument;
|
||||
|
||||
impl OcrHooks for RewriteDocument {
|
||||
fn has_guardrails(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn during_call(
|
||||
&self,
|
||||
request: OcrDuringCallRequest,
|
||||
) -> OcrHookFuture<'_, OcrDuringCallRequest> {
|
||||
Box::pin(async move {
|
||||
assert_eq!(
|
||||
request.body["document_url"],
|
||||
"data:application/pdf;base64,YWJj"
|
||||
);
|
||||
Ok(OcrDuringCallRequest {
|
||||
body: json!({"type":"document_url","document_url":"reducto://guarded.pdf"}),
|
||||
..request
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn guardrail_rewrites_document_before_upload() {
|
||||
let (base, seen, server) =
|
||||
mock_server(vec![MockResponse::json(json!({"result":{"chunks":[]}}))]).await;
|
||||
let mut request = wire_request("reducto/parse-v3", &base, json!({}));
|
||||
request.hooks = Arc::new(RewriteDocument);
|
||||
|
||||
perform_ocr(request).await.unwrap();
|
||||
server.await.unwrap();
|
||||
let requests = seen.lock().unwrap();
|
||||
assert_eq!(requests.len(), 1);
|
||||
assert!(requests[0].starts_with("POST /parse "));
|
||||
assert!(requests[0].contains("reducto://guarded.pdf"));
|
||||
}
|
||||
|
|
@ -45,6 +45,7 @@ pub(crate) fn chat_completions_error_to_pyerr(err: Error) -> PyErr {
|
|||
| Error::MissingAzureAiCredentials
|
||||
| Error::MissingAzureAiCredentialsOrAdToken
|
||||
| Error::MissingAzureDocumentIntelligenceCredentials
|
||||
| Error::MissingReductoApiKey
|
||||
| Error::Routing(_)
|
||||
// Nothing reached the provider, so serving it on Python cannot double
|
||||
// bill and is the only way the caller gets an answer at all.
|
||||
|
|
|
|||
|
|
@ -102,13 +102,15 @@ mod tests {
|
|||
use litellm_core::ocr::wire::is_supported_request;
|
||||
|
||||
#[test]
|
||||
fn native_activation_includes_azure_document_intelligence() {
|
||||
fn native_activation_includes_migrated_providers() {
|
||||
assert!(is_supported_request("model", Some("mistral")));
|
||||
assert!(is_supported_request("pixtral-12b", Some("azure_ai")));
|
||||
assert!(is_supported_request(
|
||||
"documentintelligence/prebuilt-read",
|
||||
Some("azure_ai")
|
||||
));
|
||||
assert!(is_supported_request("parse-v3", Some("reducto")));
|
||||
assert!(is_supported_request("parse-legacy", Some("reducto")));
|
||||
assert!(!is_supported_request("mistral-ocr", Some("vertex_ai")));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9400,11 +9400,9 @@ class ProviderConfigManager:
|
|||
ReductoParseV3Config,
|
||||
)
|
||||
|
||||
if model == "parse-v3":
|
||||
return ReductoParseV3Config()
|
||||
if model == "parse-legacy":
|
||||
return ReductoParseLegacyConfig()
|
||||
return None
|
||||
return ReductoParseV3Config()
|
||||
|
||||
MistralOCRConfig: Final = litellm_utils.MistralOCRConfig
|
||||
PROVIDER_TO_CONFIG_MAP: Final = {
|
||||
|
|
|
|||
|
|
@ -143,3 +143,28 @@ async def test_parse_v3_reducto_id_passthrough_skips_upload(disable_aiohttp_tran
|
|||
assert parse_request_body["input"] == "reducto://already-uploaded.pdf"
|
||||
assert parse_request_body["retrieval"]["chunk_mode"] == "section"
|
||||
assert response.pages[0].markdown.startswith("Page 1 block A")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unknown_model_uses_current_protocol_without_local_rejection(
|
||||
disable_aiohttp_transport, respx_mock
|
||||
):
|
||||
parse_route = respx_mock.post("https://platform.reducto.ai/parse").respond(
|
||||
json=_reducto_parse_response()
|
||||
)
|
||||
|
||||
response = await litellm.aocr(
|
||||
model="reducto/future-parse-model",
|
||||
document={
|
||||
"type": "document_url",
|
||||
"document_url": "reducto://already-uploaded.pdf",
|
||||
},
|
||||
api_key="test-key",
|
||||
api_base="https://platform.reducto.ai",
|
||||
)
|
||||
|
||||
assert parse_route.called
|
||||
assert json.loads(parse_route.calls[0].request.read()) == {
|
||||
"input": "reducto://already-uploaded.pdf"
|
||||
}
|
||||
assert response.model == "future-parse-model"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue