feat(rust): define canonical OCR contracts

This commit is contained in:
Yujong Lee 2026-09-02 06:50:09 -07:00 committed by GitHub
parent 6b49cb963f
commit a86333c0fb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 460 additions and 0 deletions

View file

@ -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]]

View file

@ -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

View file

@ -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 }

View 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]))
);
}
}

View file

@ -1,2 +1,5 @@
pub mod canonical;
pub mod policy;
pub mod response;
pub mod transformation;
pub mod types;

View 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);
}
}

View 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");
}
}

View file

@ -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,