1use serde::{Deserialize, Serialize};
4
5use crate::{ZaiResult, client::ZaiClient};
6
7use super::response::{
8 AssistantConversationListResponse, AssistantInvokeResponse, AssistantListResponse,
9};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
13pub enum AssistantId {
14 #[serde(rename = "65940acff94777010aa6b796")]
16 ChatGlm,
17 #[serde(rename = "65a265419d72d299a9230616")]
19 DataAnalysis,
20 #[serde(rename = "664dd7bd5bb3a13ba0f81668")]
22 Flowchart,
23 #[serde(rename = "664e0cade018d633146de0d2")]
25 MindMap,
26 #[serde(rename = "6654898292788e88ce9e7f4c")]
28 PromptEngineer,
29 #[serde(rename = "66437ef3d920bdc5c60f338e")]
31 ImageGeneration,
32 #[serde(rename = "659e54b1b8006379b4b2abd6")]
34 AiSearch,
35 #[serde(rename = "65d2f07bb2c10188f885bd89")]
37 Presentation,
38 #[serde(rename = "663058948bb259b7e8a22730")]
40 ArxivReader,
41 #[serde(rename = "65a393b3619c6f13586246cd")]
43 ProgrammerSam,
44 #[serde(rename = "65b356af6924a59d52832e54")]
46 WebNovelWriter,
47 #[serde(rename = "668fdd45405f2e3c9f71f832")]
49 EnglishGrammar,
50}
51
52#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
54pub enum AssistantModel {
55 #[default]
57 #[serde(rename = "glm-4-assistant")]
58 Glm4Assistant,
59 #[serde(rename = "glm-4-alltools")]
61 Glm4AllTools,
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
66pub enum AssistantMessageRole {
67 #[serde(rename = "user")]
69 User,
70}
71
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
74pub enum AssistantContentType {
75 #[serde(rename = "text")]
77 Text,
78 #[serde(rename = "image_url")]
80 ImageUrl,
81}
82
83#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
85pub struct AssistantImageUrl {
86 #[serde(skip_serializing_if = "Option::is_none")]
88 pub url: Option<String>,
89}
90
91impl std::fmt::Debug for AssistantImageUrl {
92 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93 formatter
94 .debug_struct("AssistantImageUrl")
95 .field("url_configured", &self.url.is_some())
96 .finish()
97 }
98}
99
100impl AssistantImageUrl {
101 pub fn new(url: impl Into<String>) -> Self {
103 Self {
104 url: Some(url.into()),
105 }
106 }
107}
108
109#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
111pub struct AssistantContentPart {
112 #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
114 pub type_: Option<AssistantContentType>,
115 #[serde(skip_serializing_if = "Option::is_none")]
117 pub text: Option<String>,
118 #[serde(skip_serializing_if = "Option::is_none")]
120 pub image_url: Option<AssistantImageUrl>,
121}
122
123impl std::fmt::Debug for AssistantContentPart {
124 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
125 formatter
126 .debug_struct("AssistantContentPart")
127 .field("type", &self.type_)
128 .field("text_configured", &self.text.is_some())
129 .field("image_url_configured", &self.image_url.is_some())
130 .finish()
131 }
132}
133
134impl AssistantContentPart {
135 pub fn text(text: impl Into<String>) -> Self {
137 Self {
138 type_: Some(AssistantContentType::Text),
139 text: Some(text.into()),
140 image_url: None,
141 }
142 }
143
144 pub fn image_url(url: impl Into<String>) -> Self {
146 Self {
147 type_: Some(AssistantContentType::ImageUrl),
148 text: None,
149 image_url: Some(AssistantImageUrl::new(url)),
150 }
151 }
152
153 fn validate(&self) -> ZaiResult<()> {
154 match self.type_ {
155 Some(AssistantContentType::Text) => {
156 let Some(text) = self.text.as_deref() else {
157 return Err(crate::client::validation::invalid(
158 "assistant text part requires text",
159 ));
160 };
161 crate::client::validation::require_non_blank(text, "assistant text")?;
162 if self.image_url.is_some() {
163 return Err(crate::client::validation::invalid(
164 "assistant text part must not contain image_url",
165 ));
166 }
167 },
168 Some(AssistantContentType::ImageUrl) => {
169 let Some(url) = self
170 .image_url
171 .as_ref()
172 .and_then(|image| image.url.as_deref())
173 else {
174 return Err(crate::client::validation::invalid(
175 "assistant image part requires image_url.url",
176 ));
177 };
178 crate::client::validation::require_non_blank(url, "assistant image URL")?;
179 if self.text.is_some() {
180 return Err(crate::client::validation::invalid(
181 "assistant image part must not contain text",
182 ));
183 }
184 },
185 None => {
186 return Err(crate::client::validation::invalid(
187 "assistant content part requires type",
188 ));
189 },
190 }
191 Ok(())
192 }
193}
194
195#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
197#[serde(untagged)]
198pub enum AssistantMessageContent {
199 Text(String),
201 Parts(Vec<AssistantContentPart>),
203}
204
205impl std::fmt::Debug for AssistantMessageContent {
206 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
207 match self {
208 Self::Text(_) => formatter.write_str("Text([REDACTED])"),
209 Self::Parts(parts) => formatter
210 .debug_struct("Parts")
211 .field("count", &parts.len())
212 .finish(),
213 }
214 }
215}
216
217impl AssistantMessageContent {
218 fn validate(&self) -> ZaiResult<()> {
219 match self {
220 Self::Text(text) => {
221 crate::client::validation::require_non_blank(text, "assistant message text")
222 },
223 Self::Parts(parts) => {
224 if parts.is_empty() {
225 return Err(crate::client::validation::invalid(
226 "assistant content parts must not be empty",
227 ));
228 }
229 parts.iter().try_for_each(AssistantContentPart::validate)
230 },
231 }
232 }
233}
234
235impl From<String> for AssistantMessageContent {
236 fn from(value: String) -> Self {
237 Self::Text(value)
238 }
239}
240
241impl From<&str> for AssistantMessageContent {
242 fn from(value: &str) -> Self {
243 Self::Text(value.to_owned())
244 }
245}
246
247impl From<Vec<AssistantContentPart>> for AssistantMessageContent {
248 fn from(value: Vec<AssistantContentPart>) -> Self {
249 Self::Parts(value)
250 }
251}
252
253#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
255pub struct AssistantMessage {
256 pub role: AssistantMessageRole,
258 pub content: AssistantMessageContent,
260}
261
262impl std::fmt::Debug for AssistantMessage {
263 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
264 formatter
265 .debug_struct("AssistantMessage")
266 .field("role", &self.role)
267 .field("content", &self.content)
268 .finish()
269 }
270}
271
272impl AssistantMessage {
273 pub fn user(content: impl Into<AssistantMessageContent>) -> Self {
275 Self {
276 role: AssistantMessageRole::User,
277 content: content.into(),
278 }
279 }
280}
281
282#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
284pub struct AssistantTranslationParameters {
285 #[serde(skip_serializing_if = "Option::is_none")]
287 pub from: Option<String>,
288 #[serde(skip_serializing_if = "Option::is_none")]
290 pub to: Option<String>,
291}
292
293impl std::fmt::Debug for AssistantTranslationParameters {
294 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
295 formatter
296 .debug_struct("AssistantTranslationParameters")
297 .field("from_configured", &self.from.is_some())
298 .field("to_configured", &self.to.is_some())
299 .finish()
300 }
301}
302
303#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
305pub struct AssistantExtraParameters {
306 #[serde(skip_serializing_if = "Option::is_none")]
308 pub translate: Option<AssistantTranslationParameters>,
309}
310
311impl std::fmt::Debug for AssistantExtraParameters {
312 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
313 formatter
314 .debug_struct("AssistantExtraParameters")
315 .field("translate_configured", &self.translate.is_some())
316 .finish()
317 }
318}
319
320#[derive(Clone, PartialEq, Serialize, Deserialize)]
322pub struct AssistantInvokeRequest {
323 pub assistant_id: AssistantId,
325 #[serde(skip_serializing_if = "Option::is_none")]
327 pub conversation_id: Option<String>,
328 pub model: AssistantModel,
330 pub messages: Vec<AssistantMessage>,
332 #[serde(default)]
335 pub stream: bool,
336 #[serde(skip_serializing_if = "Option::is_none")]
338 pub request_id: Option<String>,
339 #[serde(skip_serializing_if = "Option::is_none")]
341 pub user_id: Option<String>,
342 #[serde(skip_serializing_if = "Option::is_none")]
344 pub do_sample: Option<bool>,
345 #[serde(skip_serializing_if = "Option::is_none")]
347 pub attachments: Option<Vec<serde_json::Map<String, serde_json::Value>>>,
348 #[serde(skip_serializing_if = "Option::is_none")]
350 pub metadata: Option<serde_json::Map<String, serde_json::Value>>,
351 #[serde(skip_serializing_if = "Option::is_none")]
353 pub extra_parameters: Option<AssistantExtraParameters>,
354}
355
356impl std::fmt::Debug for AssistantInvokeRequest {
357 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
358 formatter
359 .debug_struct("AssistantInvokeRequest")
360 .field("assistant_id", &self.assistant_id)
361 .field(
362 "conversation_id_configured",
363 &self.conversation_id.is_some(),
364 )
365 .field("model", &self.model)
366 .field("message_count", &self.messages.len())
367 .field("stream", &self.stream)
368 .field("request_id_configured", &self.request_id.is_some())
369 .field("user_id_configured", &self.user_id.is_some())
370 .field("do_sample", &self.do_sample)
371 .field(
372 "attachment_count",
373 &self.attachments.as_ref().map(std::vec::Vec::len),
374 )
375 .field(
376 "metadata_field_count",
377 &self.metadata.as_ref().map(serde_json::Map::len),
378 )
379 .field(
380 "extra_parameters_configured",
381 &self.extra_parameters.is_some(),
382 )
383 .finish()
384 }
385}
386
387impl AssistantInvokeRequest {
388 pub fn new(assistant_id: AssistantId, messages: Vec<AssistantMessage>) -> Self {
390 Self {
391 assistant_id,
392 conversation_id: None,
393 model: AssistantModel::default(),
394 messages,
395 stream: false,
396 request_id: None,
397 user_id: None,
398 do_sample: None,
399 attachments: None,
400 metadata: None,
401 extra_parameters: None,
402 }
403 }
404
405 pub fn with_conversation_id(mut self, conversation_id: impl Into<String>) -> Self {
407 self.conversation_id = Some(conversation_id.into());
408 self
409 }
410
411 pub fn with_model(mut self, model: AssistantModel) -> Self {
413 self.model = model;
414 self
415 }
416
417 pub fn with_request_id(mut self, request_id: impl Into<String>) -> Self {
419 self.request_id = Some(request_id.into());
420 self
421 }
422
423 pub fn with_user_id(mut self, user_id: impl Into<String>) -> Self {
425 self.user_id = Some(user_id.into());
426 self
427 }
428
429 pub fn with_sampling(mut self, enabled: bool) -> Self {
431 self.do_sample = Some(enabled);
432 self
433 }
434
435 pub fn with_attachments(
437 mut self,
438 attachments: Vec<serde_json::Map<String, serde_json::Value>>,
439 ) -> Self {
440 self.attachments = Some(attachments);
441 self
442 }
443
444 pub fn with_metadata(mut self, metadata: serde_json::Map<String, serde_json::Value>) -> Self {
446 self.metadata = Some(metadata);
447 self
448 }
449
450 pub fn with_extra_parameters(mut self, parameters: AssistantExtraParameters) -> Self {
452 self.extra_parameters = Some(parameters);
453 self
454 }
455
456 pub fn validate(&self) -> ZaiResult<()> {
458 if self.stream {
459 return Err(crate::client::validation::invalid(
460 "assistant send_via supports only stream=false",
461 ));
462 }
463 if self.messages.is_empty() {
464 return Err(crate::client::validation::invalid(
465 "assistant messages must contain at least one item",
466 ));
467 }
468 if let Some(conversation_id) = self.conversation_id.as_deref() {
469 crate::client::validation::require_non_blank(conversation_id, "conversation_id")?;
470 }
471 if self.request_id.as_ref().is_some_and(|value| {
472 value.trim() != value || !(6..=64).contains(&value.chars().count())
473 }) {
474 return Err(crate::client::validation::invalid(
475 "assistant request_id must contain between 6 and 64 characters",
476 ));
477 }
478 if self.user_id.as_ref().is_some_and(|value| {
479 value.trim() != value || !(6..=128).contains(&value.chars().count())
480 }) {
481 return Err(crate::client::validation::invalid(
482 "assistant user_id must contain between 6 and 128 characters",
483 ));
484 }
485 for message in &self.messages {
486 message.content.validate()?;
487 }
488 if let Some(translation) = self
489 .extra_parameters
490 .as_ref()
491 .and_then(|parameters| parameters.translate.as_ref())
492 {
493 for (value, name) in [
494 (translation.from.as_deref(), "translation.from"),
495 (translation.to.as_deref(), "translation.to"),
496 ] {
497 if let Some(value) = value {
498 crate::client::validation::require_non_blank(value, name)?;
499 }
500 }
501 }
502 Ok(())
503 }
504
505 pub async fn send_via(&self, client: &ZaiClient) -> ZaiResult<AssistantInvokeResponse> {
507 self.validate()?;
508 let route = crate::client::routes::ASSISTANTS_INVOKE;
509 let url = client.endpoints().resolve_route(route, &[])?;
510 let response = client
511 .send_json::<_, AssistantInvokeResponse>(route.method(), url, self)
512 .await?;
513 response.validate()?;
514 Ok(response)
515 }
516}
517
518#[derive(Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
520pub struct AssistantListRequest {
521 pub assistant_id_list: Vec<String>,
524}
525
526impl std::fmt::Debug for AssistantListRequest {
527 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
528 formatter
529 .debug_struct("AssistantListRequest")
530 .field("assistant_id_count", &self.assistant_id_list.len())
531 .finish()
532 }
533}
534
535impl AssistantListRequest {
536 pub fn new() -> Self {
538 Self::default()
539 }
540
541 pub fn for_assistants(assistant_ids: Vec<String>) -> Self {
543 Self {
544 assistant_id_list: assistant_ids,
545 }
546 }
547
548 pub fn validate(&self) -> ZaiResult<()> {
551 self.assistant_id_list.iter().try_for_each(|assistant_id| {
552 crate::client::validation::require_non_blank(assistant_id, "assistant_id")
553 })
554 }
555
556 pub async fn send_via(&self, client: &ZaiClient) -> ZaiResult<AssistantListResponse> {
558 self.validate()?;
559 let route = crate::client::routes::ASSISTANTS_LIST;
560 let url = client.endpoints().resolve_route(route, &[])?;
561 let response = client
562 .send_json::<_, AssistantListResponse>(route.method(), url, self)
563 .await?;
564 response.validate()?;
565 Ok(response)
566 }
567}
568
569#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
571pub struct AssistantConversationListRequest {
572 pub assistant_id: AssistantId,
574 #[serde(skip_serializing_if = "Option::is_none")]
576 pub page: Option<u32>,
577 #[serde(skip_serializing_if = "Option::is_none")]
579 pub page_size: Option<u32>,
580}
581
582impl AssistantConversationListRequest {
583 pub fn new(assistant_id: AssistantId) -> Self {
585 Self {
586 assistant_id,
587 page: None,
588 page_size: None,
589 }
590 }
591
592 pub fn with_page(mut self, page: u32) -> Self {
594 self.page = Some(page);
595 self
596 }
597
598 pub fn with_page_size(mut self, page_size: u32) -> Self {
600 self.page_size = Some(page_size);
601 self
602 }
603
604 pub fn validate(&self) -> ZaiResult<()> {
606 if self.page == Some(0) {
607 return Err(crate::client::validation::invalid(
608 "assistant page must be at least 1",
609 ));
610 }
611 if self
612 .page_size
613 .is_some_and(|page_size| !(1..=100).contains(&page_size))
614 {
615 return Err(crate::client::validation::invalid(
616 "assistant page_size must be between 1 and 100",
617 ));
618 }
619 Ok(())
620 }
621
622 pub async fn send_via(
624 &self,
625 client: &ZaiClient,
626 ) -> ZaiResult<AssistantConversationListResponse> {
627 self.validate()?;
628 let route = crate::client::routes::ASSISTANTS_CONVERSATIONS;
629 let url = client.endpoints().resolve_route(route, &[])?;
630 let response = client
631 .send_json::<_, AssistantConversationListResponse>(route.method(), url, self)
632 .await?;
633 response.validate()?;
634 Ok(response)
635 }
636}
637
638#[cfg(test)]
639mod tests {
640 use super::*;
641
642 #[test]
643 fn invoke_serializes_required_fields_and_the_content_union() {
644 let request = AssistantInvokeRequest::new(
645 AssistantId::ChatGlm,
646 vec![AssistantMessage::user(vec![
647 AssistantContentPart::text("describe"),
648 AssistantContentPart::image_url("https://example.test/image.png"),
649 ])],
650 );
651 assert_eq!(
652 serde_json::to_value(request).unwrap(),
653 serde_json::json!({
654 "assistant_id": "65940acff94777010aa6b796",
655 "model": "glm-4-assistant",
656 "stream": false,
657 "messages": [{
658 "role": "user",
659 "content": [
660 {"type": "text", "text": "describe"},
661 {"type": "image_url", "image_url": {"url": "https://example.test/image.png"}}
662 ]
663 }]
664 })
665 );
666 }
667
668 #[test]
669 fn assistant_list_keeps_its_required_empty_array() {
670 assert_eq!(
671 serde_json::to_value(AssistantListRequest::new()).unwrap(),
672 serde_json::json!({"assistant_id_list": []})
673 );
674 }
675
676 #[test]
677 fn validation_enforces_frozen_shapes_and_numeric_constraints() {
678 let mut streaming = AssistantInvokeRequest::new(
679 AssistantId::ChatGlm,
680 vec![AssistantMessage::user("hello")],
681 );
682 streaming.stream = true;
683 assert!(streaming.validate().is_err());
684 assert!(
685 AssistantInvokeRequest::new(AssistantId::ChatGlm, Vec::new())
686 .validate()
687 .is_err()
688 );
689 assert!(
690 AssistantInvokeRequest::new(
691 AssistantId::ChatGlm,
692 vec![AssistantMessage::user("hello")]
693 )
694 .with_request_id("short")
695 .validate()
696 .is_err()
697 );
698 assert!(
699 AssistantInvokeRequest::new(AssistantId::ChatGlm, vec![AssistantMessage::user(" ")])
700 .validate()
701 .is_err()
702 );
703 assert!(
704 AssistantInvokeRequest::new(
705 AssistantId::ChatGlm,
706 vec![AssistantMessage::user(vec![AssistantContentPart {
707 type_: Some(AssistantContentType::Text),
708 text: None,
709 image_url: None,
710 }])]
711 )
712 .validate()
713 .is_err()
714 );
715 assert!(
716 AssistantListRequest::for_assistants(vec![" ".to_owned()])
717 .validate()
718 .is_err()
719 );
720 assert!(
721 AssistantConversationListRequest::new(AssistantId::ChatGlm)
722 .with_page(0)
723 .validate()
724 .is_err()
725 );
726 assert!(
727 AssistantConversationListRequest::new(AssistantId::ChatGlm)
728 .with_page_size(101)
729 .validate()
730 .is_err()
731 );
732 }
733
734 #[test]
735 fn debug_output_redacts_messages_urls_and_open_maps() {
736 let mut attachment = serde_json::Map::new();
737 attachment.insert(
738 "private-key".into(),
739 serde_json::json!("private-attachment"),
740 );
741 let mut metadata = serde_json::Map::new();
742 metadata.insert(
743 "private-meta-key".into(),
744 serde_json::json!("private-metadata"),
745 );
746 let request = AssistantInvokeRequest::new(
747 AssistantId::ChatGlm,
748 vec![AssistantMessage::user(vec![
749 AssistantContentPart::text("private-text"),
750 AssistantContentPart::image_url("https://private.example/image.png"),
751 ])],
752 )
753 .with_conversation_id("private-conversation")
754 .with_request_id("private-request")
755 .with_user_id("private-user")
756 .with_attachments(vec![attachment])
757 .with_metadata(metadata)
758 .with_extra_parameters(AssistantExtraParameters {
759 translate: Some(AssistantTranslationParameters {
760 from: Some("private-source".into()),
761 to: Some("private-target".into()),
762 }),
763 });
764 let debug = format!("{request:?}");
765 for secret in [
766 "private-text",
767 "private.example",
768 "private-conversation",
769 "private-request",
770 "private-user",
771 "private-key",
772 "private-attachment",
773 "private-meta-key",
774 "private-metadata",
775 "private-source",
776 "private-target",
777 ] {
778 assert!(!debug.contains(secret), "Debug leaked {secret}");
779 }
780 }
781}