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