Skip to main content

zai_rs/model/async_chat_get/
response.rs

1//! Typed responses shared by asynchronous task-submission and polling APIs.
2
3use serde::{Deserialize, Deserializer, Serialize, de::Error as _};
4
5use crate::{
6    ZaiResult,
7    client::error::{ZaiError, codes},
8    model::chat_base_response::{
9        Choice, ContentFilterInfo, TaskStatus, Usage, VideoResultItem, WebSearchInfo,
10    },
11};
12
13fn invalid_response(message: impl Into<String>) -> ZaiError {
14    ZaiError::ApiError {
15        code: codes::SDK_VALIDATION,
16        message: message.into(),
17    }
18}
19
20/// Acknowledgement returned after an asynchronous task is accepted.
21///
22/// Every field is optional in the upstream schema. A successful operation must
23/// nevertheless return at least one documented non-null field; call
24/// [`validate`](Self::validate) after deserializing an untrusted value.
25#[derive(Debug, Clone, Serialize)]
26pub struct AsyncResponse {
27    /// Model that accepted the task.
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub model: Option<String>,
30    /// Task identifier used by the async-result endpoint.
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub id: Option<String>,
33    /// Caller-supplied or generated request identifier.
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub request_id: Option<String>,
36    /// Current task status, when returned at submission time.
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub task_status: Option<TaskStatus>,
39}
40
41#[derive(Deserialize)]
42#[serde(deny_unknown_fields)]
43struct AsyncResponseWire {
44    model: Option<String>,
45    id: Option<String>,
46    request_id: Option<String>,
47    task_status: Option<TaskStatus>,
48}
49
50impl<'de> Deserialize<'de> for AsyncResponse {
51    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
52    where
53        D: Deserializer<'de>,
54    {
55        let wire = AsyncResponseWire::deserialize(deserializer)?;
56        let response = Self {
57            model: wire.model,
58            id: wire.id,
59            request_id: wire.request_id,
60            task_status: wire.task_status,
61        };
62        response
63            .validate()
64            .map_err(|error| D::Error::custom(error.to_string()))?;
65        Ok(response)
66    }
67}
68
69impl AsyncResponse {
70    /// Enforce the operation contract's non-empty response invariant.
71    pub fn validate(&self) -> ZaiResult<()> {
72        if self.model.is_some()
73            || self.id.is_some()
74            || self.request_id.is_some()
75            || self.task_status.is_some()
76        {
77            return Ok(());
78        }
79        Err(invalid_response(
80            "async task response contained no documented fields",
81        ))
82    }
83
84    /// Borrow the task identifier.
85    pub fn id(&self) -> Option<&str> {
86        self.id.as_deref()
87    }
88
89    /// Borrow the request identifier.
90    pub fn request_id(&self) -> Option<&str> {
91        self.request_id.as_deref()
92    }
93
94    /// Borrow the model identifier.
95    pub fn model(&self) -> Option<&str> {
96        self.model.as_deref()
97    }
98
99    /// Borrow the current task status.
100    pub const fn status(&self) -> Option<&TaskStatus> {
101        self.task_status.as_ref()
102    }
103}
104
105/// Descriptive alias for the acknowledgement returned by task submissions.
106pub type AsyncTaskResponse = AsyncResponse;
107
108/// A completed asynchronous chat-completion result.
109#[derive(Debug, Clone, Serialize, Deserialize)]
110#[serde(deny_unknown_fields)]
111pub struct AsyncChatTaskResult {
112    /// Task identifier.
113    #[serde(skip_serializing_if = "Option::is_none")]
114    pub id: Option<String>,
115    /// Request identifier.
116    #[serde(skip_serializing_if = "Option::is_none")]
117    pub request_id: Option<String>,
118    /// Creation time as a Unix timestamp in seconds.
119    #[serde(skip_serializing_if = "Option::is_none")]
120    pub created: Option<u64>,
121    /// Current task status, when returned with the completion payload.
122    /// One of `PROCESSING` / `SUCCESS` / `FAIL`; a successful completion
123    /// typically carries `SUCCESS` alongside `choices` and `usage`.
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub task_status: Option<TaskStatus>,
126    /// Model that generated the response.
127    #[serde(skip_serializing_if = "Option::is_none")]
128    pub model: Option<String>,
129    /// Generated choices.
130    #[serde(skip_serializing_if = "Option::is_none")]
131    pub choices: Option<Vec<Choice>>,
132    /// Token usage statistics.
133    #[serde(skip_serializing_if = "Option::is_none")]
134    pub usage: Option<Usage>,
135    /// Web-search sources used by the response.
136    #[serde(skip_serializing_if = "Option::is_none")]
137    pub web_search: Option<Vec<WebSearchInfo>>,
138    /// Content-safety classifications.
139    #[serde(skip_serializing_if = "Option::is_none")]
140    pub content_filter: Option<Vec<ContentFilterInfo>>,
141}
142
143impl AsyncChatTaskResult {
144    /// Borrow the generated choices.
145    pub fn choices(&self) -> Option<&[Choice]> {
146        self.choices.as_deref()
147    }
148
149    /// Borrow the current task status.
150    pub const fn status(&self) -> Option<&TaskStatus> {
151        self.task_status.as_ref()
152    }
153
154    fn has_documented_value(&self) -> bool {
155        self.id.is_some()
156            || self.request_id.is_some()
157            || self.created.is_some()
158            || self.task_status.is_some()
159            || self.model.is_some()
160            || self.choices.is_some()
161            || self.usage.is_some()
162            || self.web_search.is_some()
163            || self.content_filter.is_some()
164    }
165}
166
167/// A completed asynchronous video-generation result.
168#[derive(Debug, Clone, Serialize, Deserialize)]
169#[serde(deny_unknown_fields)]
170pub struct AsyncVideoTaskResult {
171    /// Model that generated the video.
172    #[serde(skip_serializing_if = "Option::is_none")]
173    pub model: Option<String>,
174    /// Current task status.
175    #[serde(skip_serializing_if = "Option::is_none")]
176    pub task_status: Option<TaskStatus>,
177    /// Generated videos.
178    #[serde(skip_serializing_if = "Option::is_none")]
179    pub video_result: Option<Vec<VideoResultItem>>,
180    /// Request identifier.
181    #[serde(skip_serializing_if = "Option::is_none")]
182    pub request_id: Option<String>,
183}
184
185impl AsyncVideoTaskResult {
186    /// Borrow the current task status.
187    pub const fn status(&self) -> Option<&TaskStatus> {
188        self.task_status.as_ref()
189    }
190
191    /// Borrow the generated video items.
192    pub fn videos(&self) -> Option<&[VideoResultItem]> {
193        self.video_result.as_deref()
194    }
195
196    fn has_documented_value(&self) -> bool {
197        self.model.is_some()
198            || self.task_status.is_some()
199            || self.video_result.is_some()
200            || self.request_id.is_some()
201    }
202}
203
204/// One generated image returned by an asynchronous image task.
205#[derive(Debug, Clone, Serialize, Deserialize)]
206#[serde(deny_unknown_fields)]
207pub struct AsyncImageResultItem {
208    /// URL of the generated image.
209    #[serde(skip_serializing_if = "Option::is_none")]
210    pub url: Option<String>,
211}
212
213impl AsyncImageResultItem {
214    /// Borrow the generated image URL.
215    pub fn url(&self) -> Option<&str> {
216        self.url.as_deref()
217    }
218}
219
220/// A completed asynchronous image-generation result.
221#[derive(Debug, Clone, Serialize, Deserialize)]
222#[serde(deny_unknown_fields)]
223pub struct AsyncImageTaskResult {
224    /// Model that generated the image.
225    #[serde(skip_serializing_if = "Option::is_none")]
226    pub model: Option<String>,
227    /// Current task status.
228    #[serde(skip_serializing_if = "Option::is_none")]
229    pub task_status: Option<TaskStatus>,
230    /// Generated images.
231    #[serde(skip_serializing_if = "Option::is_none")]
232    pub image_result: Option<Vec<AsyncImageResultItem>>,
233    /// Request identifier.
234    #[serde(skip_serializing_if = "Option::is_none")]
235    pub request_id: Option<String>,
236}
237
238impl AsyncImageTaskResult {
239    /// Borrow the current task status.
240    pub const fn status(&self) -> Option<&TaskStatus> {
241        self.task_status.as_ref()
242    }
243
244    /// Borrow the generated image items.
245    pub fn images(&self) -> Option<&[AsyncImageResultItem]> {
246        self.image_result.as_deref()
247    }
248
249    fn has_documented_value(&self) -> bool {
250        self.model.is_some()
251            || self.task_status.is_some()
252            || self.image_result.is_some()
253            || self.request_id.is_some()
254    }
255}
256
257/// Status-only task result used while a task is pending or after it fails.
258///
259/// Chat, image, and video tasks share this wire shape, so it cannot be assigned
260/// to one media-specific variant until a result field is present.
261#[derive(Debug, Clone, Serialize, Deserialize)]
262#[serde(deny_unknown_fields)]
263pub struct AsyncTaskState {
264    /// Task identifier, when supplied by the service.
265    #[serde(skip_serializing_if = "Option::is_none")]
266    pub id: Option<String>,
267    /// Model handling the task.
268    #[serde(skip_serializing_if = "Option::is_none")]
269    pub model: Option<String>,
270    /// Request identifier.
271    #[serde(skip_serializing_if = "Option::is_none")]
272    pub request_id: Option<String>,
273    /// Creation time as a Unix timestamp in seconds, when supplied by the
274    /// service while a task is still processing.
275    #[serde(skip_serializing_if = "Option::is_none")]
276    pub created: Option<u64>,
277    /// Current task status.
278    #[serde(skip_serializing_if = "Option::is_none")]
279    pub task_status: Option<TaskStatus>,
280}
281
282impl AsyncTaskState {
283    /// Borrow the current task status.
284    pub const fn status(&self) -> Option<&TaskStatus> {
285        self.task_status.as_ref()
286    }
287
288    /// Return whether the task is still processing.
289    pub fn is_processing(&self) -> bool {
290        matches!(self.task_status, Some(TaskStatus::Processing))
291    }
292
293    /// Return whether the task completed successfully without a typed payload.
294    pub fn is_success(&self) -> bool {
295        matches!(self.task_status, Some(TaskStatus::Success))
296    }
297
298    /// Return whether the task failed.
299    pub fn is_failed(&self) -> bool {
300        matches!(self.task_status, Some(TaskStatus::Fail))
301    }
302
303    fn has_documented_value(&self) -> bool {
304        self.id.is_some()
305            || self.model.is_some()
306            || self.request_id.is_some()
307            || self.created.is_some()
308            || self.task_status.is_some()
309    }
310}
311
312/// Closed set of payloads returned by the asynchronous result endpoint.
313#[derive(Debug, Clone, Serialize)]
314#[serde(untagged)]
315pub enum AsyncTaskResult {
316    /// Completed chat-completion payload.
317    Chat(AsyncChatTaskResult),
318    /// Completed video-generation payload.
319    Video(AsyncVideoTaskResult),
320    /// Completed image-generation payload.
321    Image(AsyncImageTaskResult),
322    /// Shared status-only payload for pending, failed, or result-less tasks.
323    State(AsyncTaskState),
324}
325
326impl AsyncTaskResult {
327    fn has_documented_value(&self) -> bool {
328        match self {
329            Self::Chat(result) => result.has_documented_value(),
330            Self::Video(result) => result.has_documented_value(),
331            Self::Image(result) => result.has_documented_value(),
332            Self::State(result) => result.has_documented_value(),
333        }
334    }
335
336    /// Borrow the status supplied by media and status-only payloads.
337    pub const fn status(&self) -> Option<&TaskStatus> {
338        match self {
339            Self::Chat(_) => None,
340            Self::Video(result) => result.task_status.as_ref(),
341            Self::Image(result) => result.task_status.as_ref(),
342            Self::State(result) => result.task_status.as_ref(),
343        }
344    }
345
346    /// Borrow the chat payload when this is a completed chat task.
347    pub const fn as_chat(&self) -> Option<&AsyncChatTaskResult> {
348        match self {
349            Self::Chat(result) => Some(result),
350            _ => None,
351        }
352    }
353
354    /// Borrow the video payload when this is a completed video task.
355    pub const fn as_video(&self) -> Option<&AsyncVideoTaskResult> {
356        match self {
357            Self::Video(result) => Some(result),
358            _ => None,
359        }
360    }
361
362    /// Borrow the image payload when this is a completed image task.
363    pub const fn as_image(&self) -> Option<&AsyncImageTaskResult> {
364        match self {
365            Self::Image(result) => Some(result),
366            _ => None,
367        }
368    }
369
370    /// Borrow the status-only payload.
371    pub const fn as_state(&self) -> Option<&AsyncTaskState> {
372        match self {
373            Self::State(result) => Some(result),
374            _ => None,
375        }
376    }
377}
378
379impl<'de> Deserialize<'de> for AsyncTaskResult {
380    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
381    where
382        D: Deserializer<'de>,
383    {
384        let value = serde_json::Value::deserialize(deserializer)?;
385        let object = value
386            .as_object()
387            .ok_or_else(|| D::Error::custom("async task result must be a JSON object"))?;
388
389        let result = if object.contains_key("image_result") {
390            Self::Image(
391                serde_json::from_value(value)
392                    .map_err(|error| D::Error::custom(error.to_string()))?,
393            )
394        } else if object.contains_key("video_result") {
395            Self::Video(
396                serde_json::from_value(value)
397                    .map_err(|error| D::Error::custom(error.to_string()))?,
398            )
399        } else if [
400            "choices",
401            "usage",
402            "web_search",
403            "content_filter",
404        ]
405        .iter()
406        .any(|field| object.contains_key(*field))
407            || (object.contains_key("id")
408                && !object.contains_key("task_status")
409                && !object.contains_key("model")
410                && !object.contains_key("request_id"))
411        {
412            Self::Chat(
413                serde_json::from_value(value)
414                    .map_err(|error| D::Error::custom(error.to_string()))?,
415            )
416        } else if ["id", "model", "request_id", "created", "task_status"]
417            .iter()
418            .any(|field| object.get(*field).is_some_and(|value| !value.is_null()))
419        {
420            Self::State(
421                serde_json::from_value(value)
422                    .map_err(|error| D::Error::custom(error.to_string()))?,
423            )
424        } else {
425            return Err(D::Error::custom(
426                "async task result contained no documented non-null fields",
427            ));
428        };
429
430        if !result.has_documented_value() {
431            return Err(D::Error::custom(
432                "async task result contained no documented non-null fields",
433            ));
434        }
435
436        Ok(result)
437    }
438}
439
440#[cfg(test)]
441mod tests {
442    use super::*;
443
444    #[test]
445    fn task_acknowledgement_requires_a_documented_value() {
446        assert!(serde_json::from_str::<AsyncResponse>("{}").is_err());
447        assert!(serde_json::from_str::<AsyncResponse>(r#"{"id":null}"#).is_err());
448
449        let response: AsyncResponse = serde_json::from_value(serde_json::json!({
450            "id": "task-1",
451            "task_status": "PROCESSING"
452        }))
453        .unwrap();
454        assert_eq!(response.id(), Some("task-1"));
455        assert!(matches!(response.status(), Some(TaskStatus::Processing)));
456        assert!(response.validate().is_ok());
457    }
458
459    #[test]
460    fn task_result_rejects_empty_and_unknown_payloads() {
461        assert!(serde_json::from_str::<AsyncTaskResult>("{}").is_err());
462        assert!(serde_json::from_str::<AsyncTaskResult>(r#"{"task_id":"legacy"}"#).is_err());
463        assert!(serde_json::from_str::<AsyncTaskResult>(r#"{"choices":null}"#).is_err());
464        assert!(serde_json::from_str::<AsyncTaskResult>(r#"{"video_result":null}"#).is_err());
465        assert!(serde_json::from_str::<AsyncTaskResult>(r#"{"image_result":null}"#).is_err());
466    }
467
468    #[test]
469    fn task_result_selects_each_closed_variant() {
470        let chat: AsyncTaskResult = serde_json::from_value(serde_json::json!({
471            "id": "chat-1",
472            "choices": [{"index": 0, "message": {"role": "assistant", "content": "done"}}]
473        }))
474        .unwrap();
475        assert_eq!(
476            chat.as_chat()
477                .and_then(|value| value.choices())
478                .map(<[_]>::len),
479            Some(1)
480        );
481
482        let video: AsyncTaskResult = serde_json::from_value(serde_json::json!({
483            "model": "cogvideox-3",
484            "task_status": "SUCCESS",
485            "video_result": [{"url": "https://example.com/video.mp4"}]
486        }))
487        .unwrap();
488        assert_eq!(
489            video
490                .as_video()
491                .and_then(|value| value.videos())
492                .map(<[_]>::len),
493            Some(1)
494        );
495
496        let image: AsyncTaskResult = serde_json::from_value(serde_json::json!({
497            "model": "glm-image",
498            "task_status": "SUCCESS",
499            "image_result": [{"url": "https://example.com/image.png"}]
500        }))
501        .unwrap();
502        assert_eq!(
503            image
504                .as_image()
505                .and_then(|value| value.images())
506                .map(<[_]>::len),
507            Some(1)
508        );
509
510        let state: AsyncTaskResult = serde_json::from_value(serde_json::json!({
511            "request_id": "request-1",
512            "task_status": "PROCESSING"
513        }))
514        .unwrap();
515        assert!(state.as_state().is_some_and(AsyncTaskState::is_processing));
516    }
517
518    /// The async-result endpoint returns `task_status` while a chat task is
519    /// still processing, and alongside `choices`/`usage` once it succeeds.
520    /// Both shapes must deserialize; the processing payload routes to the
521    /// shared `State` variant and the success payload to `Chat`. Regression
522    /// test for the "unknown field `task_status`" failure seen while polling.
523    #[test]
524    fn task_result_handles_chat_task_status_at_every_stage() {
525        // Processing chat task: the polling endpoint echoes the creation
526        // timestamp and task_status but no completion payload.
527        let processing: AsyncTaskResult = serde_json::from_value(serde_json::json!({
528            "id": "task-1",
529            "model": "glm-5.2",
530            "created": 1_700_000_000_u64,
531            "request_id": "request-1",
532            "task_status": "PROCESSING"
533        }))
534        .unwrap();
535        let processing_state = processing
536            .as_state()
537            .expect("processing chat task must route to the State variant");
538        assert!(processing_state.is_processing());
539
540        // Completed chat task: the success payload now carries task_status.
541        let done: AsyncTaskResult = serde_json::from_value(serde_json::json!({
542            "id": "task-1",
543            "model": "glm-5.2",
544            "created": 1_700_000_000_u64,
545            "request_id": "request-1",
546            "task_status": "SUCCESS",
547            "choices": [{"index": 0, "message": {"role": "assistant", "content": "done"}}]
548        }))
549        .unwrap();
550        let chat = done
551            .as_chat()
552            .expect("completed chat task must route to the Chat variant");
553        assert_eq!(
554            chat.status(),
555            Some(&TaskStatus::Success),
556            "completed chat result should expose its task_status"
557        );
558        assert_eq!(
559            chat.choices().map(<[_]>::len),
560            Some(1),
561            "completed chat result should expose its choices"
562        );
563    }
564}