use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::{
ZaiResult,
client::error::{ZaiError, codes},
};
fn response_error(message: impl Into<String>) -> ZaiError {
ZaiError::ApiError {
code: codes::SDK_VALIDATION,
message: message.into(),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum LayoutLabel {
#[serde(rename = "image")]
Image,
#[serde(rename = "text")]
Text,
#[serde(rename = "formula")]
Formula,
#[serde(rename = "table")]
Table,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct LayoutDetail {
pub index: i64,
pub label: LayoutLabel,
#[serde(skip_serializing_if = "Option::is_none")]
pub bbox_2d: Option<[f64; 4]>,
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub height: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub width: Option<i64>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PageInfo {
pub width: i64,
pub height: i64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DataInfo {
pub num_pages: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub pages: Option<Vec<PageInfo>>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct LayoutPromptTokensDetails {
#[serde(skip_serializing_if = "Option::is_none")]
pub cached_tokens: Option<f64>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct LayoutUsage {
#[serde(skip_serializing_if = "Option::is_none")]
pub prompt_tokens: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub completion_tokens: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub prompt_tokens_details: Option<LayoutPromptTokensDetails>,
#[serde(skip_serializing_if = "Option::is_none")]
pub total_tokens: Option<i64>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct LayoutParsingResponse {
pub id: String,
pub created: i64,
pub model: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub md_results: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub layout_details: Option<Vec<Vec<LayoutDetail>>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub layout_visualization: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub data_info: Option<DataInfo>,
#[serde(skip_serializing_if = "Option::is_none")]
pub usage: Option<LayoutUsage>,
#[serde(skip_serializing_if = "Option::is_none")]
pub request_id: Option<String>,
}
impl LayoutParsingResponse {
pub fn validate(&self) -> ZaiResult<()> {
let invalid_bbox = self
.layout_details
.iter()
.flatten()
.flatten()
.filter_map(|detail| detail.bbox_2d)
.flatten()
.any(|coordinate| !coordinate.is_finite() || !(0.0..=1.0).contains(&coordinate));
if invalid_bbox {
return Err(response_error(
"layout response bbox_2d coordinates must be finite values in 0..=1",
));
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReaderStylesheet {
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
pub type_: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReaderExternalResources {
#[serde(skip_serializing_if = "Option::is_none")]
pub stylesheet: Option<BTreeMap<String, ReaderStylesheet>>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReaderMetadata {
#[serde(skip_serializing_if = "Option::is_none")]
pub keywords: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub viewport: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(rename = "format-detection", skip_serializing_if = "Option::is_none")]
pub format_detection: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReaderResult {
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub external: Option<ReaderExternalResources>,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<ReaderMetadata>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReaderResponse {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub created: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub request_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reader_result: Option<ReaderResult>,
}
impl ReaderResponse {
pub fn validate(&self) -> ZaiResult<()> {
if self.id.is_some()
|| self.created.is_some()
|| self.request_id.is_some()
|| self.model.is_some()
|| self.reader_result.is_some()
{
return Ok(());
}
Err(response_error(
"reader response contained no documented fields",
))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn layout_required_fields_and_fixed_bbox_length_are_enforced() {
assert!(
serde_json::from_value::<LayoutParsingResponse>(serde_json::json!({
"created": 1,
"model": "GLM-OCR"
}))
.is_err()
);
assert!(
serde_json::from_value::<LayoutParsingResponse>(serde_json::json!({
"id": "task-1",
"created": 1,
"model": "GLM-OCR",
"layout_details": [[{"index": 0, "label": "text", "bbox_2d": [0.0, 1.0]}]]
}))
.is_err()
);
assert!(serde_json::from_value::<LayoutDetail>(serde_json::json!({"index": 1})).is_err());
assert!(serde_json::from_value::<DataInfo>(serde_json::json!({"pages": []})).is_err());
assert!(serde_json::from_value::<PageInfo>(serde_json::json!({"width": 1})).is_err());
}
#[test]
fn layout_validation_rejects_out_of_range_coordinates() {
let response: LayoutParsingResponse = serde_json::from_value(serde_json::json!({
"id": "task-1",
"created": 1,
"model": "GLM-OCR",
"layout_details": [[{
"index": 0,
"label": "text",
"bbox_2d": [0.0, 0.0, 1.2, 1.0]
}]]
}))
.unwrap();
assert!(response.validate().is_err());
}
#[test]
fn reader_response_models_typed_additional_properties() {
let response: ReaderResponse = serde_json::from_value(serde_json::json!({
"reader_result": {
"external": {"stylesheet": {"main": {"type": "text/css"}}},
"metadata": {"format-detection": "telephone=no"}
}
}))
.unwrap();
assert_eq!(
response
.reader_result
.as_ref()
.unwrap()
.external
.as_ref()
.unwrap()
.stylesheet
.as_ref()
.unwrap()["main"]
.type_
.as_deref(),
Some("text/css")
);
assert!(response.validate().is_ok());
assert!(
serde_json::from_value::<ReaderResponse>(serde_json::json!({}))
.unwrap()
.validate()
.is_err()
);
}
}