zai-rs 0.6.0

Type-safe async Rust SDK for Zhipu AI (BigModel) APIs
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
use std::marker::PhantomData;
use std::pin::Pin;
use std::task::{Context, Poll};

use futures_util::{Stream, StreamExt};
use serde::Serialize;
use validator::Validate;

use super::super::{
    chat_base_request::*, chat_base_response::ChatCompletionResponse,
    chat_stream_response::ChatStreamResponse, tools::*, traits::*,
};
use crate::client::ZaiClient;

/// Authenticated typed response stream returned by [`ChatCompletion::stream_via`].
///
/// [`next`](Self::next) is available without importing an extension trait. The
/// type also implements [`Stream`] for combinators from `futures-util`.
pub struct ChatStream {
    inner: Pin<Box<dyn Stream<Item = crate::ZaiResult<ChatStreamResponse>> + Send + 'static>>,
}

impl ChatStream {
    /// Await the next decoded chat chunk, or `None` after `[DONE]`.
    ///
    /// An unexpected EOF is yielded as an error before the stream terminates.
    pub async fn next(&mut self) -> Option<crate::ZaiResult<ChatStreamResponse>> {
        self.inner.next().await
    }
}

impl Stream for ChatStream {
    type Item = crate::ZaiResult<ChatStreamResponse>;

    fn poll_next(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<Option<Self::Item>> {
        self.inner.as_mut().poll_next(context)
    }
}

/// Typed chat-completion request builder sent through a [`ZaiClient`].
///
/// Generic over the model `N`, the message type `M`, and a stream type-state
/// `S` (`StreamOff` by default, `StreamOn` after
/// [`enable_stream`](Self::enable_stream)).
/// Credentials, endpoint selection and transport policy are owned by the
/// [`ZaiClient`] supplied to [`send_via`](Self::send_via).
///
/// Audio chat deliberately has no tool surface:
///
/// ```compile_fail
/// use zai_rs::model::{
///     chat::ChatCompletion,
///     chat_message_types::VoiceMessage,
///     chat_models::GLM4_voice,
///     tools::Function,
/// };
///
/// ChatCompletion::new(GLM4_voice {}, VoiceMessage::new_user()).add_tool(
///     Function::new("lookup", "Lookup", serde_json::json!({"type": "object"})),
/// );
/// ```
///
/// ```compile_fail
/// use zai_rs::model::{
///     chat::ChatCompletion,
///     chat_message_types::VoiceMessage,
///     chat_models::GLM4_voice,
///     tools::ToolChoice,
/// };
///
/// ChatCompletion::new(GLM4_voice {}, VoiceMessage::new_user())
///     .with_tool_choice(ToolChoice::auto());
/// ```
///
/// ```compile_fail
/// use zai_rs::model::{
///     chat::ChatCompletion,
///     chat_message_types::VoiceMessage,
///     chat_models::GLM4_voice,
///     tools::ResponseFormat,
/// };
///
/// ChatCompletion::new(GLM4_voice {}, VoiceMessage::new_user())
///     .with_response_format(ResponseFormat::JsonObject);
/// ```
///
/// Vision chat accepts a bare [`Function`](crate::model::tools::Function), not
/// the text schema's retrieval, web-search, or MCP tool variants:
///
/// ```compile_fail
/// use zai_rs::model::{
///     chat::ChatCompletion,
///     chat_message_types::VisionMessage,
///     chat_models::GLM4_6v,
///     tools::{Retrieval, Tools},
/// };
///
/// ChatCompletion::new(GLM4_6v {}, VisionMessage::new_user()).add_tool(
///     Tools::Retrieval { retrieval: Retrieval::new("kb") },
/// );
/// ```
///
/// ```compile_fail
/// use zai_rs::model::{
///     chat::ChatCompletion,
///     chat_message_types::VisionMessage,
///     chat_models::GLM4_6v,
///     tools::ResponseFormat,
/// };
///
/// ChatCompletion::new(GLM4_6v {}, VisionMessage::new_user())
///     .with_response_format(ResponseFormat::JsonObject);
/// ```
pub struct ChatCompletion<N, M, S = StreamOff>
where
    N: ChatRequestModel + Chat,
    M: Serialize,
    (N, M): Bounded,
    ChatBody<N, M>: Serialize,
    S: StreamState,
{
    body: ChatBody<N, M>,
    _stream: PhantomData<S>,
}

impl<N, M, S> ChatCompletion<N, M, S>
where
    N: ChatRequestModel + Chat,
    M: Serialize,
    (N, M): Bounded,
    ChatBody<N, M>: Serialize,
    S: StreamState,
{
    /// Borrow the frozen request body.
    pub const fn body(&self) -> &ChatBody<N, M> {
        &self.body
    }
}

impl<N, M> ChatCompletion<N, M, StreamOff>
where
    N: ChatRequestModel + Chat,
    M: Serialize,
    (N, M): Bounded,
    ChatBody<N, M>: Serialize,
{
    /// Create a new chat request from a model and the first message batch.
    pub fn new(model: N, messages: M) -> ChatCompletion<N, M, StreamOff> {
        let body = ChatBody::new(model, messages);
        ChatCompletion {
            body,
            _stream: PhantomData,
        }
    }

    /// Append one message to the conversation.
    pub fn add_message(mut self, message: M) -> Self {
        self.body = self.body.add_message(message);
        self
    }
    /// Append multiple messages to the conversation.
    pub fn extend_messages(mut self, messages: impl IntoIterator<Item = M>) -> Self {
        self.body = self.body.extend_messages(messages);
        self
    }
    /// Set the client-side request id.
    pub fn with_request_id(mut self, request_id: impl Into<String>) -> Self {
        self.body = self.body.with_request_id(request_id);
        self
    }
    /// Enable/disable sampling (`do_sample`).
    pub fn with_do_sample(mut self, do_sample: bool) -> Self {
        self.body = self.body.with_do_sample(do_sample);
        self
    }
    /// Set the sampling temperature.
    pub fn with_temperature(mut self, temperature: f64) -> Self {
        self.body = self.body.with_temperature(temperature);
        self
    }
    /// Set the nucleus-sampling probability (`top_p`).
    pub fn with_top_p(mut self, top_p: f64) -> Self {
        self.body = self.body.with_top_p(top_p);
        self
    }
    /// Set the maximum number of tokens to generate.
    pub fn with_max_tokens(mut self, max_tokens: u32) -> Self {
        self.body = self.body.with_max_tokens(max_tokens);
        self
    }
    /// Add a single tool to the request.
    pub fn add_tool(mut self, tool: N::Tool) -> Self
    where
        N: ChatToolSupport,
    {
        self.body = self.body.add_tool(tool);
        self
    }
    /// Add multiple tools to the request at once.
    pub fn add_tools(mut self, tools: impl IntoIterator<Item = N::Tool>) -> Self
    where
        N: ChatToolSupport,
    {
        self.body = self.body.add_tools(tools);
        self
    }
    /// Set automatic tool selection.
    pub fn with_tool_choice(mut self, tool_choice: ToolChoice) -> Self
    where
        N: ChatToolSupport,
    {
        self.body = self.body.with_tool_choice(tool_choice);
        self
    }
    /// Remove all tools and their selection policy.
    pub fn clear_tools(mut self) -> Self
    where
        N: ChatToolSupport,
    {
        self.body = self.body.clear_tools();
        self
    }
    /// Set the text response format.
    pub fn with_response_format(mut self, format: ResponseFormat) -> Self
    where
        N: ResponseFormatEnable,
    {
        self.body = self.body.with_response_format(format);
        self
    }
    /// Enable or disable audio-output watermarking.
    pub fn with_watermark_enabled(mut self, enabled: bool) -> Self
    where
        N: WatermarkEnable,
    {
        self.body = self.body.with_watermark_enabled(enabled);
        self
    }
    /// Set the end-user id (used for abuse monitoring).
    pub fn with_user_id(mut self, user_id: impl Into<String>) -> Self {
        self.body = self.body.with_user_id(user_id);
        self
    }
    /// Add a stop sequence that halts generation when encountered.
    pub fn with_stop(mut self, stop: impl Into<String>) -> Self {
        self.body = self.body.with_stop(stop);
        self
    }

    /// Enable thinking mode (requires a model that supports it).
    pub fn with_thinking(mut self, thinking: ThinkingType) -> Self
    where
        N: ThinkEnable,
    {
        self.body = self.body.with_thinking(thinking);
        self
    }

    /// Set the reasoning effort (GLM-5.2+ only).
    pub fn with_reasoning_effort(mut self, effort: ReasoningEffort) -> Self
    where
        N: ReasoningEffortEnable,
    {
        self.body = self.body.with_reasoning_effort(effort);
        self
    }

    /// Enables streaming for this chat completion request.
    pub fn enable_stream(mut self) -> ChatCompletion<N, M, StreamOn> {
        self.body.set_stream(Some(true));
        ChatCompletion {
            body: self.body,
            _stream: PhantomData,
        }
    }

    /// Validate request parameters for non-stream chat (StreamOff)
    pub fn validate(&self) -> crate::ZaiResult<()> {
        self.body
            .validate()
            .map_err(crate::client::error::ZaiError::from)?;
        if matches!(self.body.stream(), Some(true)) {
            return Err(crate::client::error::ZaiError::ApiError {
                code: crate::client::error::codes::SDK_VALIDATION,
                message: "stream=true detected; use enable_stream() and streaming APIs instead"
                    .to_string(),
            });
        }
        Ok(())
    }

    /// Submit the request via a [`ZaiClient`] and await the (non-streaming)
    /// response.
    pub async fn send_via(&self, client: &ZaiClient) -> crate::ZaiResult<ChatCompletionResponse>
    where
        N: serde::Serialize,
        M: serde::Serialize,
    {
        self.validate()?;
        let route = crate::client::routes::CHAT_COMPLETE;
        let url = client.endpoints().resolve_route(route, &[])?;
        client
            .send_json::<_, ChatCompletionResponse>(route.method(), url, &self.body)
            .await
    }

    /// Submit using a coding-plan endpoint via a [`ZaiClient`].
    pub async fn send_via_coding_plan(
        &self,
        client: &ZaiClient,
    ) -> crate::ZaiResult<ChatCompletionResponse>
    where
        N: serde::Serialize,
        M: serde::Serialize,
    {
        self.validate()?;
        let route = crate::client::routes::CHAT_COMPLETE_CODING;
        let url = client.endpoints().resolve_route(route, &[])?;
        client
            .send_json::<_, ChatCompletionResponse>(route.method(), url, &self.body)
            .await
    }
}

impl<N, M> ChatCompletion<N, M, StreamOn>
where
    N: ChatRequestModel + Chat,
    M: Serialize,
    (N, M): Bounded,
    ChatBody<N, M>: Serialize,
{
    /// Enable/disable tool-call streaming (requires a model that supports it).
    pub fn with_tool_stream(mut self, tool_stream: bool) -> Self
    where
        N: ToolStreamEnable,
    {
        self.body = self.body.with_tool_stream(tool_stream);
        self
    }

    /// Disables streaming for this chat completion request.
    pub fn disable_stream(mut self) -> ChatCompletion<N, M, StreamOff> {
        self.body.set_stream(Some(false));
        self.body.clear_tool_stream();
        ChatCompletion {
            body: self.body,
            _stream: PhantomData,
        }
    }

    /// Submit the request and yield parsed chat chunks from its SSE response.
    ///
    /// Authentication, response content-type validation, timeouts, and body
    /// limits remain inside [`ZaiClient`]; the API secret is never exposed to
    /// the caller. Streaming POST requests are not retried or redirected.
    pub async fn stream_via(&self, client: &ZaiClient) -> crate::ZaiResult<ChatStream>
    where
        N: serde::Serialize,
        M: serde::Serialize,
    {
        self.body
            .validate()
            .map_err(crate::client::error::ZaiError::from)?;
        let url = client
            .endpoints()
            .resolve_route(crate::client::routes::CHAT_COMPLETE, &[])?;
        let raw = client
            .send_sse_json(
                crate::client::routes::CHAT_COMPLETE.method(),
                url,
                &self.body,
            )
            .await?;
        let stream = crate::model::sse_parser::decode_required_done_stream(raw, |payload| {
            serde_json::from_slice::<ChatStreamResponse>(payload)
                .map_err(crate::ZaiError::from)
                .and_then(validate_stream_finish_reason)
        });
        Ok(ChatStream { inner: stream })
    }
}

fn validate_stream_finish_reason(
    chunk: ChatStreamResponse,
) -> crate::ZaiResult<ChatStreamResponse> {
    let failure = chunk
        .choices
        .iter()
        .filter_map(|choice| choice.finish_reason.as_deref())
        .find_map(|reason| match reason {
            "sensitive" => Some((1301, "stream stopped by content policy")),
            "network_error" => Some((1234, "stream stopped by an upstream inference error")),
            "model_context_window_exceeded" => {
                Some((1261, "stream exceeded the model context window"))
            },
            _ => None,
        });

    match failure {
        Some((code, message)) => Err(crate::ZaiError::from_api_response(
            200,
            code,
            message.to_string(),
        )),
        None => Ok(chunk),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::model::{chat_message_types::TextMessage, chat_models::GLM5_2};

    #[test]
    fn stream_field_is_owned_by_the_type_state() {
        let request = ChatCompletion::new(GLM5_2 {}, TextMessage::user("hello"));
        let json = serde_json::to_value(request.body()).unwrap();
        assert!(json.get("stream").is_none());
        assert!(json.get("tool_stream").is_none());

        let request = request.enable_stream().with_tool_stream(true);
        let json = serde_json::to_value(request.body()).unwrap();
        assert_eq!(json["stream"], true);
        assert_eq!(json["tool_stream"], true);
    }
}