Skip to main content

zai_rs/services/assistants/
request.rs

1//! Typed requests for assistant invocation and discovery endpoints.
2
3use serde::{Deserialize, Serialize};
4
5use crate::{ZaiResult, client::ZaiClient};
6
7use super::response::{
8    AssistantConversationListResponse, AssistantInvokeResponse, AssistantListResponse,
9};
10
11/// Assistant identifiers accepted by the frozen assistant API schema.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
13pub enum AssistantId {
14    /// Official ChatGLM assistant.
15    #[serde(rename = "65940acff94777010aa6b796")]
16    ChatGlm,
17    /// Official data-analysis assistant.
18    #[serde(rename = "65a265419d72d299a9230616")]
19    DataAnalysis,
20    /// Official complex-flowchart assistant.
21    #[serde(rename = "664dd7bd5bb3a13ba0f81668")]
22    Flowchart,
23    /// Official mind-map assistant.
24    #[serde(rename = "664e0cade018d633146de0d2")]
25    MindMap,
26    /// Official prompt-engineering assistant.
27    #[serde(rename = "6654898292788e88ce9e7f4c")]
28    PromptEngineer,
29    /// Official image-generation assistant.
30    #[serde(rename = "66437ef3d920bdc5c60f338e")]
31    ImageGeneration,
32    /// Official AI-search assistant.
33    #[serde(rename = "659e54b1b8006379b4b2abd6")]
34    AiSearch,
35    /// Official presentation assistant.
36    #[serde(rename = "65d2f07bb2c10188f885bd89")]
37    Presentation,
38    /// Official arXiv paper-reading assistant.
39    #[serde(rename = "663058948bb259b7e8a22730")]
40    ArxivReader,
41    /// Official programmer assistant Sam.
42    #[serde(rename = "65a393b3619c6f13586246cd")]
43    ProgrammerSam,
44    /// Official web-novel writing assistant.
45    #[serde(rename = "65b356af6924a59d52832e54")]
46    WebNovelWriter,
47    /// Official English-grammar assistant.
48    #[serde(rename = "668fdd45405f2e3c9f71f832")]
49    EnglishGrammar,
50}
51
52/// Model accepted by the assistant invocation endpoint.
53#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
54pub enum AssistantModel {
55    /// Standard assistant model.
56    #[default]
57    #[serde(rename = "glm-4-assistant")]
58    Glm4Assistant,
59    /// Assistant model with all-tools support.
60    #[serde(rename = "glm-4-alltools")]
61    Glm4AllTools,
62}
63
64/// Role accepted by an assistant request message.
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
66pub enum AssistantMessageRole {
67    /// User-authored input (the only role in the frozen schema).
68    #[serde(rename = "user")]
69    User,
70}
71
72/// Type of a multimodal assistant request part.
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
74pub enum AssistantContentType {
75    /// Text input.
76    #[serde(rename = "text")]
77    Text,
78    /// Image URL input.
79    #[serde(rename = "image_url")]
80    ImageUrl,
81}
82
83/// URL wrapper used by an image request part.
84#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
85pub struct AssistantImageUrl {
86    /// Image URL.
87    #[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    /// Create an image URL wrapper.
102    pub fn new(url: impl Into<String>) -> Self {
103        Self {
104            url: Some(url.into()),
105        }
106    }
107}
108
109/// One part of a multimodal assistant request message.
110#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
111pub struct AssistantContentPart {
112    /// Part type.
113    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
114    pub type_: Option<AssistantContentType>,
115    /// Text input for a text part.
116    #[serde(skip_serializing_if = "Option::is_none")]
117    pub text: Option<String>,
118    /// URL input for an image part.
119    #[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    /// Create a text part.
136    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    /// Create an image URL part.
145    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/// Plain text or multimodal content in an assistant request message.
196#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
197#[serde(untagged)]
198pub enum AssistantMessageContent {
199    /// Plain text input.
200    Text(String),
201    /// Multimodal input parts.
202    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/// One user message sent to an assistant.
254#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
255pub struct AssistantMessage {
256    /// Message role (`user`).
257    pub role: AssistantMessageRole,
258    /// Plain text or multimodal message content.
259    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    /// Create a user message.
274    pub fn user(content: impl Into<AssistantMessageContent>) -> Self {
275        Self {
276            role: AssistantMessageRole::User,
277            content: content.into(),
278        }
279    }
280}
281
282/// Translation options in an assistant request.
283#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
284pub struct AssistantTranslationParameters {
285    /// Source language.
286    #[serde(skip_serializing_if = "Option::is_none")]
287    pub from: Option<String>,
288    /// Target language.
289    #[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/// Typed extra parameters in an assistant request.
304#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
305pub struct AssistantExtraParameters {
306    /// Translation configuration.
307    #[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/// Invoke an assistant.
321#[derive(Clone, PartialEq, Serialize, Deserialize)]
322pub struct AssistantInvokeRequest {
323    /// Assistant identifier.
324    pub assistant_id: AssistantId,
325    /// Existing conversation identifier, when continuing a conversation.
326    #[serde(skip_serializing_if = "Option::is_none")]
327    pub conversation_id: Option<String>,
328    /// Assistant model. This required field is always serialized.
329    pub model: AssistantModel,
330    /// User messages. The frozen schema requires at least one item.
331    pub messages: Vec<AssistantMessage>,
332    /// Streaming flag. The JSON-only `send_via` path requires this to remain
333    /// `false` and serializes it explicitly because the server default is true.
334    #[serde(default)]
335    pub stream: bool,
336    /// Caller-provided request identifier (`6..=64` characters).
337    #[serde(skip_serializing_if = "Option::is_none")]
338    pub request_id: Option<String>,
339    /// End-user identifier (`6..=128` characters).
340    #[serde(skip_serializing_if = "Option::is_none")]
341    pub user_id: Option<String>,
342    /// Whether sampling is enabled.
343    #[serde(skip_serializing_if = "Option::is_none")]
344    pub do_sample: Option<bool>,
345    /// Open attachment objects from the upstream schema.
346    #[serde(skip_serializing_if = "Option::is_none")]
347    pub attachments: Option<Vec<serde_json::Map<String, serde_json::Value>>>,
348    /// Open metadata map (`additionalProperties: true`).
349    #[serde(skip_serializing_if = "Option::is_none")]
350    pub metadata: Option<serde_json::Map<String, serde_json::Value>>,
351    /// Typed assistant-specific parameters.
352    #[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    /// Create an invocation with all OpenAPI-required fields.
389    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    /// Continue an existing conversation.
406    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    /// Select the assistant model.
412    pub fn with_model(mut self, model: AssistantModel) -> Self {
413        self.model = model;
414        self
415    }
416
417    /// Set the caller-provided request identifier.
418    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    /// Set the end-user identifier.
424    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    /// Select the sampling behavior.
430    pub fn with_sampling(mut self, enabled: bool) -> Self {
431        self.do_sample = Some(enabled);
432        self
433    }
434
435    /// Set attachment objects.
436    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    /// Set open metadata fields.
445    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    /// Set typed assistant-specific parameters.
451    pub fn with_extra_parameters(mut self, parameters: AssistantExtraParameters) -> Self {
452        self.extra_parameters = Some(parameters);
453        self
454    }
455
456    /// Validate OpenAPI length and minimum-item constraints.
457    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    /// Send the request through `client`.
506    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/// List assistants available to the current account.
519#[derive(Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
520pub struct AssistantListRequest {
521    /// Assistant identifiers to query. An empty required array queries all
522    /// assistants, matching the upstream default.
523    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    /// Create a request that lists all available assistants.
537    pub fn new() -> Self {
538        Self::default()
539    }
540
541    /// Create a request restricted to selected assistants.
542    pub fn for_assistants(assistant_ids: Vec<String>) -> Self {
543        Self {
544            assistant_id_list: assistant_ids,
545        }
546    }
547
548    /// Reject blank identifiers while preserving the documented empty-list
549    /// meaning of "all assistants".
550    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    /// Send the request through `client`.
557    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/// List conversations for one assistant.
570#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
571pub struct AssistantConversationListRequest {
572    /// Assistant identifier.
573    pub assistant_id: AssistantId,
574    /// Page number (`>= 1`).
575    #[serde(skip_serializing_if = "Option::is_none")]
576    pub page: Option<u32>,
577    /// Page size (`1..=100`).
578    #[serde(skip_serializing_if = "Option::is_none")]
579    pub page_size: Option<u32>,
580}
581
582impl AssistantConversationListRequest {
583    /// Create a conversation-list request for one assistant.
584    pub fn new(assistant_id: AssistantId) -> Self {
585        Self {
586            assistant_id,
587            page: None,
588            page_size: None,
589        }
590    }
591
592    /// Set the page number.
593    pub fn with_page(mut self, page: u32) -> Self {
594        self.page = Some(page);
595        self
596    }
597
598    /// Set the page size.
599    pub fn with_page_size(mut self, page_size: u32) -> Self {
600        self.page_size = Some(page_size);
601        self
602    }
603
604    /// Validate OpenAPI pagination constraints.
605    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    /// Send the request through `client`.
623    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}