use serde::{Deserialize, Deserializer, Serialize, de::Error as _};
use crate::{
ZaiResult,
client::error::{ZaiError, codes},
model::chat_base_response::{
Choice, ContentFilterInfo, TaskStatus, Usage, VideoResultItem, WebSearchInfo,
},
};
fn invalid_response(message: impl Into<String>) -> ZaiError {
ZaiError::ApiError {
code: codes::SDK_VALIDATION,
message: message.into(),
}
}
#[derive(Debug, Clone, Serialize)]
pub struct AsyncResponse {
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub request_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub task_status: Option<TaskStatus>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct AsyncResponseWire {
model: Option<String>,
id: Option<String>,
request_id: Option<String>,
task_status: Option<TaskStatus>,
}
impl<'de> Deserialize<'de> for AsyncResponse {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let wire = AsyncResponseWire::deserialize(deserializer)?;
let response = Self {
model: wire.model,
id: wire.id,
request_id: wire.request_id,
task_status: wire.task_status,
};
response
.validate()
.map_err(|error| D::Error::custom(error.to_string()))?;
Ok(response)
}
}
impl AsyncResponse {
pub fn validate(&self) -> ZaiResult<()> {
if self.model.is_some()
|| self.id.is_some()
|| self.request_id.is_some()
|| self.task_status.is_some()
{
return Ok(());
}
Err(invalid_response(
"async task response contained no documented fields",
))
}
pub fn id(&self) -> Option<&str> {
self.id.as_deref()
}
pub fn request_id(&self) -> Option<&str> {
self.request_id.as_deref()
}
pub fn model(&self) -> Option<&str> {
self.model.as_deref()
}
pub const fn status(&self) -> Option<&TaskStatus> {
self.task_status.as_ref()
}
}
pub type AsyncTaskResponse = AsyncResponse;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AsyncChatTaskResult {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub request_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub created: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub task_status: Option<TaskStatus>,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub choices: Option<Vec<Choice>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub usage: Option<Usage>,
#[serde(skip_serializing_if = "Option::is_none")]
pub web_search: Option<Vec<WebSearchInfo>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub content_filter: Option<Vec<ContentFilterInfo>>,
}
impl AsyncChatTaskResult {
pub fn choices(&self) -> Option<&[Choice]> {
self.choices.as_deref()
}
pub const fn status(&self) -> Option<&TaskStatus> {
self.task_status.as_ref()
}
fn has_documented_value(&self) -> bool {
self.id.is_some()
|| self.request_id.is_some()
|| self.created.is_some()
|| self.task_status.is_some()
|| self.model.is_some()
|| self.choices.is_some()
|| self.usage.is_some()
|| self.web_search.is_some()
|| self.content_filter.is_some()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AsyncVideoTaskResult {
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub task_status: Option<TaskStatus>,
#[serde(skip_serializing_if = "Option::is_none")]
pub video_result: Option<Vec<VideoResultItem>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub request_id: Option<String>,
}
impl AsyncVideoTaskResult {
pub const fn status(&self) -> Option<&TaskStatus> {
self.task_status.as_ref()
}
pub fn videos(&self) -> Option<&[VideoResultItem]> {
self.video_result.as_deref()
}
fn has_documented_value(&self) -> bool {
self.model.is_some()
|| self.task_status.is_some()
|| self.video_result.is_some()
|| self.request_id.is_some()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AsyncImageResultItem {
#[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
}
impl AsyncImageResultItem {
pub fn url(&self) -> Option<&str> {
self.url.as_deref()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AsyncImageTaskResult {
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub task_status: Option<TaskStatus>,
#[serde(skip_serializing_if = "Option::is_none")]
pub image_result: Option<Vec<AsyncImageResultItem>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub request_id: Option<String>,
}
impl AsyncImageTaskResult {
pub const fn status(&self) -> Option<&TaskStatus> {
self.task_status.as_ref()
}
pub fn images(&self) -> Option<&[AsyncImageResultItem]> {
self.image_result.as_deref()
}
fn has_documented_value(&self) -> bool {
self.model.is_some()
|| self.task_status.is_some()
|| self.image_result.is_some()
|| self.request_id.is_some()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AsyncTaskState {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub request_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub created: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub task_status: Option<TaskStatus>,
}
impl AsyncTaskState {
pub const fn status(&self) -> Option<&TaskStatus> {
self.task_status.as_ref()
}
pub fn is_processing(&self) -> bool {
matches!(self.task_status, Some(TaskStatus::Processing))
}
pub fn is_success(&self) -> bool {
matches!(self.task_status, Some(TaskStatus::Success))
}
pub fn is_failed(&self) -> bool {
matches!(self.task_status, Some(TaskStatus::Fail))
}
fn has_documented_value(&self) -> bool {
self.id.is_some()
|| self.model.is_some()
|| self.request_id.is_some()
|| self.created.is_some()
|| self.task_status.is_some()
}
}
#[derive(Debug, Clone, Serialize)]
#[serde(untagged)]
pub enum AsyncTaskResult {
Chat(AsyncChatTaskResult),
Video(AsyncVideoTaskResult),
Image(AsyncImageTaskResult),
State(AsyncTaskState),
}
impl AsyncTaskResult {
fn has_documented_value(&self) -> bool {
match self {
Self::Chat(result) => result.has_documented_value(),
Self::Video(result) => result.has_documented_value(),
Self::Image(result) => result.has_documented_value(),
Self::State(result) => result.has_documented_value(),
}
}
pub const fn status(&self) -> Option<&TaskStatus> {
match self {
Self::Chat(_) => None,
Self::Video(result) => result.task_status.as_ref(),
Self::Image(result) => result.task_status.as_ref(),
Self::State(result) => result.task_status.as_ref(),
}
}
pub const fn as_chat(&self) -> Option<&AsyncChatTaskResult> {
match self {
Self::Chat(result) => Some(result),
_ => None,
}
}
pub const fn as_video(&self) -> Option<&AsyncVideoTaskResult> {
match self {
Self::Video(result) => Some(result),
_ => None,
}
}
pub const fn as_image(&self) -> Option<&AsyncImageTaskResult> {
match self {
Self::Image(result) => Some(result),
_ => None,
}
}
pub const fn as_state(&self) -> Option<&AsyncTaskState> {
match self {
Self::State(result) => Some(result),
_ => None,
}
}
}
impl<'de> Deserialize<'de> for AsyncTaskResult {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = serde_json::Value::deserialize(deserializer)?;
let object = value
.as_object()
.ok_or_else(|| D::Error::custom("async task result must be a JSON object"))?;
let result = if object.contains_key("image_result") {
Self::Image(
serde_json::from_value(value)
.map_err(|error| D::Error::custom(error.to_string()))?,
)
} else if object.contains_key("video_result") {
Self::Video(
serde_json::from_value(value)
.map_err(|error| D::Error::custom(error.to_string()))?,
)
} else if [
"choices",
"usage",
"web_search",
"content_filter",
]
.iter()
.any(|field| object.contains_key(*field))
|| (object.contains_key("id")
&& !object.contains_key("task_status")
&& !object.contains_key("model")
&& !object.contains_key("request_id"))
{
Self::Chat(
serde_json::from_value(value)
.map_err(|error| D::Error::custom(error.to_string()))?,
)
} else if ["id", "model", "request_id", "created", "task_status"]
.iter()
.any(|field| object.get(*field).is_some_and(|value| !value.is_null()))
{
Self::State(
serde_json::from_value(value)
.map_err(|error| D::Error::custom(error.to_string()))?,
)
} else {
return Err(D::Error::custom(
"async task result contained no documented non-null fields",
));
};
if !result.has_documented_value() {
return Err(D::Error::custom(
"async task result contained no documented non-null fields",
));
}
Ok(result)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn task_acknowledgement_requires_a_documented_value() {
assert!(serde_json::from_str::<AsyncResponse>("{}").is_err());
assert!(serde_json::from_str::<AsyncResponse>(r#"{"id":null}"#).is_err());
let response: AsyncResponse = serde_json::from_value(serde_json::json!({
"id": "task-1",
"task_status": "PROCESSING"
}))
.unwrap();
assert_eq!(response.id(), Some("task-1"));
assert!(matches!(response.status(), Some(TaskStatus::Processing)));
assert!(response.validate().is_ok());
}
#[test]
fn task_result_rejects_empty_and_unknown_payloads() {
assert!(serde_json::from_str::<AsyncTaskResult>("{}").is_err());
assert!(serde_json::from_str::<AsyncTaskResult>(r#"{"task_id":"legacy"}"#).is_err());
assert!(serde_json::from_str::<AsyncTaskResult>(r#"{"choices":null}"#).is_err());
assert!(serde_json::from_str::<AsyncTaskResult>(r#"{"video_result":null}"#).is_err());
assert!(serde_json::from_str::<AsyncTaskResult>(r#"{"image_result":null}"#).is_err());
}
#[test]
fn task_result_selects_each_closed_variant() {
let chat: AsyncTaskResult = serde_json::from_value(serde_json::json!({
"id": "chat-1",
"choices": [{"index": 0, "message": {"role": "assistant", "content": "done"}}]
}))
.unwrap();
assert_eq!(
chat.as_chat()
.and_then(|value| value.choices())
.map(<[_]>::len),
Some(1)
);
let video: AsyncTaskResult = serde_json::from_value(serde_json::json!({
"model": "cogvideox-3",
"task_status": "SUCCESS",
"video_result": [{"url": "https://example.com/video.mp4"}]
}))
.unwrap();
assert_eq!(
video
.as_video()
.and_then(|value| value.videos())
.map(<[_]>::len),
Some(1)
);
let image: AsyncTaskResult = serde_json::from_value(serde_json::json!({
"model": "glm-image",
"task_status": "SUCCESS",
"image_result": [{"url": "https://example.com/image.png"}]
}))
.unwrap();
assert_eq!(
image
.as_image()
.and_then(|value| value.images())
.map(<[_]>::len),
Some(1)
);
let state: AsyncTaskResult = serde_json::from_value(serde_json::json!({
"request_id": "request-1",
"task_status": "PROCESSING"
}))
.unwrap();
assert!(state.as_state().is_some_and(AsyncTaskState::is_processing));
}
#[test]
fn task_result_handles_chat_task_status_at_every_stage() {
let processing: AsyncTaskResult = serde_json::from_value(serde_json::json!({
"id": "task-1",
"model": "glm-5.2",
"created": 1_700_000_000_u64,
"request_id": "request-1",
"task_status": "PROCESSING"
}))
.unwrap();
let processing_state = processing
.as_state()
.expect("processing chat task must route to the State variant");
assert!(processing_state.is_processing());
let done: AsyncTaskResult = serde_json::from_value(serde_json::json!({
"id": "task-1",
"model": "glm-5.2",
"created": 1_700_000_000_u64,
"request_id": "request-1",
"task_status": "SUCCESS",
"choices": [{"index": 0, "message": {"role": "assistant", "content": "done"}}]
}))
.unwrap();
let chat = done
.as_chat()
.expect("completed chat task must route to the Chat variant");
assert_eq!(
chat.status(),
Some(&TaskStatus::Success),
"completed chat result should expose its task_status"
);
assert_eq!(
chat.choices().map(<[_]>::len),
Some(1),
"completed chat result should expose its choices"
);
}
}