1use crate::config::TimeoutsConfig;
2use crate::config::constants::{env_vars, models, urls};
3use crate::config::core::{AnthropicConfig, ModelConfig, PromptCachingConfig};
4use crate::llm::error_display;
5use crate::llm::provider::{
6 LLMError, LLMProvider, LLMRequest, LLMResponse, LLMStream, LLMStreamEvent,
7};
8use async_stream::try_stream;
9use async_trait::async_trait;
10
11use reqwest::Client as HttpClient;
12use serde_json::{Map, Value};
13use std::borrow::Cow;
14
15use super::common::{
16 ensure_model, impl_llm_client, map_finish_reason_common, parse_json_response,
17 parse_response_openai_format, resolve_model, serialize_messages_openai_format,
18 serialize_tools_openai_format, validate_supported_models,
19};
20use super::error_handling::handle_openai_http_error;
21
22const PROVIDER_NAME: &str = "Z.AI";
23const PROVIDER_KEY: &str = "zai";
24
25pub struct ZAIProvider {
26 api_key: String,
27 http_client: HttpClient,
28 base_url: String,
29 model: String,
30 model_behavior: Option<ModelConfig>,
31}
32
33impl ZAIProvider {
34 pub fn new(api_key: String) -> Self {
35 Self::with_model_internal(
36 api_key,
37 models::zai::DEFAULT_MODEL.to_string(),
38 None,
39 None,
40 None,
41 )
42 }
43
44 pub fn with_model(api_key: String, model: String) -> Self {
45 Self::with_model_internal(api_key, model, None, None, None)
46 }
47
48 pub fn new_with_client(
49 api_key: String,
50 model: String,
51 http_client: reqwest::Client,
52 base_url: String,
53 _timeouts: TimeoutsConfig,
54 ) -> Self {
55 Self {
56 api_key,
57 http_client,
58 base_url,
59 model,
60 model_behavior: None,
61 }
62 }
63
64 pub fn from_config(
65 api_key: Option<String>,
66 model: Option<String>,
67 base_url: Option<String>,
68 _prompt_cache: Option<PromptCachingConfig>,
69 timeouts: Option<TimeoutsConfig>,
70 _anthropic: Option<AnthropicConfig>,
71 model_behavior: Option<ModelConfig>,
72 ) -> Self {
73 let api_key_value = api_key.unwrap_or_default();
74 let model_value = resolve_model(model, models::zai::DEFAULT_MODEL);
75
76 Self::with_model_internal(
77 api_key_value,
78 model_value,
79 base_url,
80 timeouts,
81 model_behavior,
82 )
83 }
84
85 fn with_model_internal(
86 api_key: String,
87 model: String,
88 base_url: Option<String>,
89 timeouts: Option<TimeoutsConfig>,
90 model_behavior: Option<ModelConfig>,
91 ) -> Self {
92 use crate::llm::http_client::HttpClientFactory;
93
94 let timeouts = timeouts.unwrap_or_default();
95
96 Self {
97 api_key,
98 http_client: HttpClientFactory::for_llm(&timeouts),
99 base_url: resolve_zai_base_url(base_url),
100 model,
101 model_behavior,
102 }
103 }
104
105 fn convert_to_zai_format(&self, request: &LLMRequest) -> Result<Value, LLMError> {
106 let mut payload = Map::new();
107 let normalized_model = normalize_model_id(&request.model);
108 let has_preserved_reasoning = request.messages.iter().any(|message| {
109 message.role == crate::llm::provider::MessageRole::Assistant
110 && message
111 .reasoning
112 .as_ref()
113 .is_some_and(|reasoning| !reasoning.is_empty())
114 });
115
116 payload.insert(
117 "model".to_owned(),
118 Value::String(normalized_model.into_owned()),
119 );
120 payload.insert(
121 "messages".to_owned(),
122 Value::Array(serialize_messages_openai_format(request, PROVIDER_KEY)?),
123 );
124
125 if let Some(max_tokens) = request.max_tokens {
126 payload.insert(
127 "max_tokens".to_owned(),
128 Value::Number(serde_json::Number::from(max_tokens as u64)),
129 );
130 }
131
132 if let Some(temperature) = request.temperature {
133 payload.insert(
134 "temperature".to_owned(),
135 Value::Number(serde_json::Number::from_f64(temperature as f64).ok_or_else(
136 || LLMError::InvalidRequest {
137 message: "Invalid temperature value".to_string(),
138 metadata: None,
139 },
140 )?),
141 );
142 }
143 if let Some(top_p) = request.top_p {
144 payload.insert(
145 "top_p".to_owned(),
146 Value::Number(serde_json::Number::from_f64(top_p as f64).ok_or_else(|| {
147 LLMError::InvalidRequest {
148 message: "Invalid top_p value".to_string(),
149 metadata: None,
150 }
151 })?),
152 );
153 }
154 if let Some(do_sample) = request.do_sample {
155 payload.insert("do_sample".to_owned(), Value::Bool(do_sample));
156 }
157
158 if request.stream {
159 payload.insert("stream".to_string(), Value::Bool(true));
160 if request
161 .tools
162 .as_ref()
163 .is_some_and(|tools| !tools.is_empty())
164 {
165 payload.insert("tool_stream".to_string(), Value::Bool(true));
166 }
167 }
168
169 if let Some(tools) = &request.tools
170 && let Some(serialized_tools) = serialize_tools_openai_format(tools)
171 {
172 payload.insert("tools".to_string(), Value::Array(serialized_tools));
173 }
174
175 if request.output_format.is_some() {
176 payload.insert(
177 "response_format".to_owned(),
178 serde_json::json!({ "type": "json_object" }),
179 );
180 }
181
182 if let Some(choice) = &request.tool_choice {
183 let tool_choice_value = match choice {
184 crate::llm::provider::ToolChoice::Auto => choice.to_provider_format(PROVIDER_KEY),
185 _ => Value::String("auto".to_string()),
186 };
187 payload.insert("tool_choice".to_string(), tool_choice_value);
188 } else if request
189 .tools
190 .as_ref()
191 .is_some_and(|tools| !tools.is_empty())
192 {
193 payload.insert("tool_choice".to_string(), Value::String("auto".to_string()));
194 }
195
196 if let Some(effort) = request.reasoning_effort {
197 if effort == crate::config::types::ReasoningEffortLevel::None {
198 payload.insert(
199 "thinking".to_owned(),
200 serde_json::json!({"type": "disabled"}),
201 );
202 return Ok(Value::Object(payload));
203 }
204
205 use crate::config::models::Provider;
206 use crate::llm::rig_adapter::RigProviderCapabilities;
207 if let Some(reasoning_params) =
208 RigProviderCapabilities::new(Provider::ZAI, &request.model)
209 .reasoning_parameters(effort)
210 && let Some(params_obj) = reasoning_params.as_object()
211 {
212 for (k, v) in params_obj {
213 payload.insert(k.clone(), v.clone());
214 }
215 }
216 }
217
218 if has_preserved_reasoning {
219 if let Some(thinking) = payload.get_mut("thinking").and_then(Value::as_object_mut) {
220 thinking.insert("clear_thinking".to_owned(), Value::Bool(false));
221 } else {
222 payload.insert(
223 "thinking".to_owned(),
224 serde_json::json!({
225 "type": "enabled",
226 "clear_thinking": false
227 }),
228 );
229 }
230 }
231
232 Ok(Value::Object(payload))
233 }
234}
235
236fn normalize_model_id<'a>(model: &'a str) -> Cow<'a, str> {
237 if model == models::zai::GLM_5_LEGACY {
238 Cow::Owned(models::zai::GLM_5.to_string())
239 } else {
240 Cow::Borrowed(model)
241 }
242}
243
244#[async_trait]
245impl LLMProvider for ZAIProvider {
246 fn name(&self) -> &str {
247 PROVIDER_KEY
248 }
249
250 fn supports_reasoning(&self, model: &str) -> bool {
251 model.contains("glm")
254 || self
255 .model_behavior
256 .as_ref()
257 .and_then(|b| b.model_supports_reasoning)
258 .unwrap_or(false)
259 }
260
261 fn supports_reasoning_effort(&self, model: &str) -> bool {
262 model.contains("glm")
264 || self
265 .model_behavior
266 .as_ref()
267 .and_then(|b| b.model_supports_reasoning_effort)
268 .unwrap_or(false)
269 }
270
271 async fn generate(&self, mut request: LLMRequest) -> Result<LLMResponse, LLMError> {
272 let model = ensure_model(&mut request, &self.model);
273
274 let payload = self.convert_to_zai_format(&request)?;
275 let url = format!("{}/chat/completions", self.base_url.trim_end_matches('/'));
276
277 let response = self
278 .http_client
279 .post(&url)
280 .bearer_auth(&self.api_key)
281 .header("Accept-Language", "en-US,en")
282 .json(&payload)
283 .send()
284 .await
285 .map_err(|e| {
286 let formatted_error = error_display::format_llm_error(
287 PROVIDER_NAME,
288 &format!("Network error: {}", e),
289 );
290 LLMError::Network {
291 message: formatted_error,
292 metadata: None,
293 }
294 })?;
295
296 let response = handle_openai_http_error(response, PROVIDER_NAME, "ZAI_API_KEY").await?;
297 let response_json = parse_json_response(response, PROVIDER_NAME).await?;
298
299 parse_response_openai_format::<fn(&Value, &Value) -> Option<String>>(
300 response_json,
301 PROVIDER_NAME,
302 model,
303 false,
304 None,
305 )
306 }
307
308 async fn stream(&self, mut request: LLMRequest) -> Result<LLMStream, LLMError> {
309 let model = ensure_model(&mut request, &self.model);
310
311 self.validate_request(&request)?;
312 request.stream = true;
313
314 let payload = self.convert_to_zai_format(&request)?;
315 let url = format!("{}/chat/completions", self.base_url.trim_end_matches('/'));
316
317 let response = self
318 .http_client
319 .post(&url)
320 .bearer_auth(&self.api_key)
321 .header("Accept-Language", "en-US,en")
322 .json(&payload)
323 .send()
324 .await
325 .map_err(|e| {
326 let formatted_error = error_display::format_llm_error(
327 PROVIDER_NAME,
328 &format!("Network error: {}", e),
329 );
330 LLMError::Network {
331 message: formatted_error,
332 metadata: None,
333 }
334 })?;
335
336 let response = handle_openai_http_error(response, PROVIDER_NAME, "ZAI_API_KEY").await?;
337
338 let bytes_stream = response.bytes_stream();
339 let (event_tx, event_rx) =
340 tokio::sync::mpsc::unbounded_channel::<Result<LLMStreamEvent, LLMError>>();
341 let tx = event_tx.clone();
342
343 let model_clone = model.clone();
344 tokio::spawn(async move {
345 let mut aggregator =
346 crate::llm::providers::shared::StreamAggregator::new(model_clone.clone());
347
348 let result = crate::llm::providers::shared::process_openai_stream(
349 bytes_stream,
350 PROVIDER_NAME,
351 model_clone,
352 |value| {
353 if let Some(choices) = value.get("choices").and_then(|c| c.as_array())
354 && let Some(choice) = choices.first()
355 {
356 if let Some(delta) = choice.get("delta") {
357 if let Some(content) = delta.get("content").and_then(|c| c.as_str()) {
358 for event in aggregator.handle_content(content) {
359 let _ = tx.send(Ok(event));
360 }
361 }
362
363 if let Some(reasoning) =
364 delta.get("reasoning_content").and_then(|c| c.as_str())
365 && let Some(d) = aggregator.handle_reasoning(reasoning)
366 {
367 let _ = tx.send(Ok(LLMStreamEvent::Reasoning { delta: d }));
368 }
369
370 if let Some(tool_calls) =
371 delta.get("tool_calls").and_then(|tc| tc.as_array())
372 {
373 aggregator.handle_tool_calls(tool_calls);
374 }
375 }
376
377 if let Some(reason) = choice.get("finish_reason").and_then(|r| r.as_str()) {
378 aggregator.set_finish_reason(map_finish_reason_common(reason));
379 }
380 }
381
382 if let Some(_usage_value) = value.get("usage")
383 && let Some(usage) =
384 crate::llm::providers::common::parse_usage_openai_format(&value, false)
385 {
386 aggregator.set_usage(usage);
387 }
388 Ok(())
389 },
390 )
391 .await;
392
393 match result {
394 Ok(_) => {
395 let response = aggregator.finalize();
396 let _ = tx.send(Ok(LLMStreamEvent::Completed {
397 response: Box::new(response),
398 }));
399 }
400 Err(err) => {
401 let _ = tx.send(Err(err));
402 }
403 }
404 });
405
406 let stream = try_stream! {
407 let mut receiver = event_rx;
408 while let Some(event) = receiver.recv().await {
409 yield event?;
410 }
411 };
412
413 Ok(Box::pin(stream))
414 }
415
416 fn supported_models(&self) -> Vec<String> {
417 models::zai::SUPPORTED_MODELS
418 .iter()
419 .map(|model| model.to_string())
420 .collect()
421 }
422
423 fn validate_request(&self, request: &LLMRequest) -> Result<(), LLMError> {
424 validate_supported_models(
425 request,
426 PROVIDER_NAME,
427 PROVIDER_KEY,
428 models::zai::SUPPORTED_MODELS,
429 )
430 }
431}
432
433fn resolve_zai_base_url(base_url: Option<String>) -> String {
434 if let Some(url) = base_url {
435 let trimmed = url.trim();
436 if !trimmed.is_empty() {
437 return trimmed.to_string();
438 }
439 }
440
441 if let Ok(value) = std::env::var(env_vars::ZAI_BASE_URL) {
442 let trimmed = value.trim();
443 if !trimmed.is_empty() {
444 return trimmed.to_string();
445 }
446 }
447
448 if let Ok(legacy) = std::env::var(env_vars::Z_AI_BASE_URL) {
449 let trimmed = legacy.trim();
450 if !trimmed.is_empty() {
451 return trimmed.to_string();
452 }
453 }
454
455 urls::ZAI_API_BASE.to_string()
456}
457
458impl_llm_client!(ZAIProvider);
459
460#[cfg(test)]
461mod tests {
462 use super::{ZAIProvider, normalize_model_id, resolve_zai_base_url};
463 use crate::config::constants::models;
464 use crate::config::types::ReasoningEffortLevel;
465 use crate::llm::provider::{LLMRequest, Message, ToolChoice, ToolDefinition};
466 use std::sync::Arc;
467
468 #[test]
469 fn normalizes_legacy_glm5_model_id() {
470 assert_eq!(
471 normalize_model_id(models::zai::GLM_5_LEGACY),
472 models::zai::GLM_5
473 );
474 }
475
476 #[test]
477 fn keeps_canonical_glm5_model_id() {
478 assert_eq!(normalize_model_id(models::zai::GLM_5), models::zai::GLM_5);
479 }
480
481 #[test]
482 fn keeps_glm47_model_id() {
483 assert_eq!(normalize_model_id(models::zai::GLM_47), models::zai::GLM_47);
484 }
485
486 #[test]
487 fn payload_includes_top_p() {
488 let provider = ZAIProvider::new("test-key".to_string());
489 let request = LLMRequest {
490 model: models::zai::GLM_5.to_string(),
491 messages: vec![Message::user("hello".to_string())],
492 top_p: Some(0.95),
493 ..Default::default()
494 };
495
496 let payload = provider
497 .convert_to_zai_format(&request)
498 .expect("payload should be valid");
499 let top_p = payload
500 .get("top_p")
501 .and_then(|v| v.as_f64())
502 .expect("top_p should be present");
503 assert!((top_p - 0.95).abs() < 1e-6);
504 }
505
506 #[test]
507 fn payload_enables_tool_stream_when_streaming_with_tools() {
508 let provider = ZAIProvider::new("test-key".to_string());
509 let request = LLMRequest {
510 model: models::zai::GLM_5.to_string(),
511 messages: vec![Message::user("hello".to_string())],
512 stream: true,
513 tools: Some(Arc::new(vec![ToolDefinition::function(
514 "get_weather".to_string(),
515 "Get weather".to_string(),
516 serde_json::json!({
517 "type": "object",
518 "properties": {
519 "location": {"type": "string"}
520 },
521 "required": ["location"]
522 }),
523 )])),
524 ..Default::default()
525 };
526
527 let payload = provider
528 .convert_to_zai_format(&request)
529 .expect("payload should be valid");
530 assert_eq!(payload.get("stream").and_then(|v| v.as_bool()), Some(true));
531 assert_eq!(
532 payload.get("tool_stream").and_then(|v| v.as_bool()),
533 Some(true)
534 );
535 }
536
537 #[test]
538 fn payload_streaming_without_tools_does_not_set_tool_stream() {
539 let provider = ZAIProvider::new("test-key".to_string());
540 let request = LLMRequest {
541 model: models::zai::GLM_5.to_string(),
542 messages: vec![Message::user("hello".to_string())],
543 stream: true,
544 ..Default::default()
545 };
546
547 let payload = provider
548 .convert_to_zai_format(&request)
549 .expect("payload should be valid");
550 assert_eq!(payload.get("stream").and_then(|v| v.as_bool()), Some(true));
551 assert!(payload.get("tool_stream").is_none());
552 }
553
554 #[test]
555 fn zai_base_url_uses_explicit_override() {
556 let resolved =
557 resolve_zai_base_url(Some("https://api.z.ai/api/coding/paas/v4".to_string()));
558 assert_eq!(resolved, "https://api.z.ai/api/coding/paas/v4");
559 }
560
561 #[test]
562 fn payload_includes_do_sample() {
563 let provider = ZAIProvider::new("test-key".to_string());
564 let request = LLMRequest {
565 model: models::zai::GLM_5.to_string(),
566 messages: vec![Message::user("hello".to_string())],
567 do_sample: Some(false),
568 ..Default::default()
569 };
570
571 let payload = provider
572 .convert_to_zai_format(&request)
573 .expect("payload should be valid");
574 assert_eq!(
575 payload.get("do_sample").and_then(|v| v.as_bool()),
576 Some(false)
577 );
578 }
579
580 #[test]
581 fn payload_disables_thinking_for_none_effort() {
582 let provider = ZAIProvider::new("test-key".to_string());
583 let request = LLMRequest {
584 model: models::zai::GLM_5.to_string(),
585 messages: vec![Message::user("hello".to_string())],
586 reasoning_effort: Some(ReasoningEffortLevel::None),
587 ..Default::default()
588 };
589
590 let payload = provider
591 .convert_to_zai_format(&request)
592 .expect("payload should be valid");
593 assert_eq!(
594 payload
595 .get("thinking")
596 .and_then(|v| v.get("type"))
597 .and_then(|v| v.as_str()),
598 Some("disabled")
599 );
600 }
601
602 #[test]
603 fn payload_enables_thinking_for_low_effort() {
604 let provider = ZAIProvider::new("test-key".to_string());
605 let request = LLMRequest {
606 model: models::zai::GLM_5.to_string(),
607 messages: vec![Message::user("hello".to_string())],
608 reasoning_effort: Some(ReasoningEffortLevel::Low),
609 ..Default::default()
610 };
611
612 let payload = provider
613 .convert_to_zai_format(&request)
614 .expect("payload should be valid");
615 assert_eq!(
616 payload
617 .get("thinking")
618 .and_then(|v| v.get("type"))
619 .and_then(|v| v.as_str()),
620 Some("enabled")
621 );
622 assert_eq!(
623 payload.get("thinking_effort").and_then(|v| v.as_str()),
624 Some("low")
625 );
626 }
627
628 #[test]
629 fn payload_enables_preserved_thinking_when_reasoning_history_present() {
630 let provider = ZAIProvider::new("test-key".to_string());
631 let mut assistant = Message::assistant("tool planning".to_string());
632 assistant.reasoning = Some("reason step 1".to_string());
633
634 let request = LLMRequest {
635 model: models::zai::GLM_5.to_string(),
636 messages: vec![assistant],
637 ..Default::default()
638 };
639
640 let payload = provider
641 .convert_to_zai_format(&request)
642 .expect("payload should be valid");
643 assert_eq!(
644 payload
645 .get("thinking")
646 .and_then(|v| v.get("type"))
647 .and_then(|v| v.as_str()),
648 Some("enabled")
649 );
650 assert_eq!(
651 payload
652 .get("thinking")
653 .and_then(|v| v.get("clear_thinking"))
654 .and_then(|v| v.as_bool()),
655 Some(false)
656 );
657 }
658
659 #[test]
660 fn payload_serializes_assistant_reasoning_content() {
661 let provider = ZAIProvider::new("test-key".to_string());
662 let mut assistant = Message::assistant("answer".to_string());
663 assistant.reasoning = Some("chain".to_string());
664
665 let request = LLMRequest {
666 model: models::zai::GLM_5.to_string(),
667 messages: vec![assistant],
668 ..Default::default()
669 };
670
671 let payload = provider
672 .convert_to_zai_format(&request)
673 .expect("payload should be valid");
674 let messages = payload
675 .get("messages")
676 .and_then(|v| v.as_array())
677 .expect("messages should be serialized");
678 let first = messages.first().expect("at least one message");
679 assert_eq!(
680 first.get("reasoning_content").and_then(|v| v.as_str()),
681 Some("chain")
682 );
683 }
684
685 #[test]
686 fn payload_serializes_web_search_tool() {
687 let provider = ZAIProvider::new("test-key".to_string());
688 let request = LLMRequest {
689 model: models::zai::GLM_5.to_string(),
690 messages: vec![Message::user("latest economic events".to_string())],
691 tools: Some(Arc::new(vec![ToolDefinition::web_search(
692 serde_json::json!({
693 "enable": true,
694 "search_engine": "search-prime",
695 "count": 5
696 }),
697 )])),
698 ..Default::default()
699 };
700
701 let payload = provider
702 .convert_to_zai_format(&request)
703 .expect("payload should be valid");
704 let tools = payload
705 .get("tools")
706 .and_then(|v| v.as_array())
707 .expect("tools should be serialized");
708 let first = tools.first().expect("at least one tool");
709 assert_eq!(
710 first.get("type").and_then(|v| v.as_str()),
711 Some("web_search")
712 );
713 assert_eq!(
714 first
715 .get("web_search")
716 .and_then(|v| v.get("search_engine"))
717 .and_then(|v| v.as_str()),
718 Some("search-prime")
719 );
720 }
721
722 #[test]
723 fn payload_tool_choice_auto_when_requested() {
724 let provider = ZAIProvider::new("test-key".to_string());
725 let request = LLMRequest {
726 model: models::zai::GLM_5.to_string(),
727 messages: vec![Message::user("hello".to_string())],
728 tool_choice: Some(ToolChoice::auto()),
729 ..Default::default()
730 };
731
732 let payload = provider
733 .convert_to_zai_format(&request)
734 .expect("payload should be valid");
735 assert_eq!(
736 payload.get("tool_choice").and_then(|v| v.as_str()),
737 Some("auto")
738 );
739 }
740
741 #[test]
742 fn payload_forces_tool_choice_to_auto_for_non_auto_modes() {
743 let provider = ZAIProvider::new("test-key".to_string());
744 let request = LLMRequest {
745 model: models::zai::GLM_5.to_string(),
746 messages: vec![Message::user("hello".to_string())],
747 tool_choice: Some(ToolChoice::none()),
748 ..Default::default()
749 };
750
751 let payload = provider
752 .convert_to_zai_format(&request)
753 .expect("payload should be valid");
754 assert_eq!(
755 payload.get("tool_choice").and_then(|v| v.as_str()),
756 Some("auto")
757 );
758 }
759
760 #[test]
761 fn payload_defaults_tool_choice_to_auto_when_tools_provided() {
762 let provider = ZAIProvider::new("test-key".to_string());
763 let request = LLMRequest {
764 model: models::zai::GLM_5.to_string(),
765 messages: vec![Message::user("hello".to_string())],
766 tools: Some(Arc::new(vec![ToolDefinition::function(
767 "get_weather".to_string(),
768 "Get weather".to_string(),
769 serde_json::json!({
770 "type": "object",
771 "properties": {
772 "location": {"type": "string"}
773 },
774 "required": ["location"]
775 }),
776 )])),
777 ..Default::default()
778 };
779
780 let payload = provider
781 .convert_to_zai_format(&request)
782 .expect("payload should be valid");
783 assert_eq!(
784 payload.get("tool_choice").and_then(|v| v.as_str()),
785 Some("auto")
786 );
787 }
788
789 #[test]
790 fn payload_enables_json_mode_when_output_format_requested() {
791 let provider = ZAIProvider::new("test-key".to_string());
792 let request = LLMRequest {
793 model: models::zai::GLM_5.to_string(),
794 messages: vec![Message::user("return json".to_string())],
795 output_format: Some(serde_json::json!({
796 "type": "object",
797 "properties": {
798 "sentiment": {"type": "string"}
799 }
800 })),
801 ..Default::default()
802 };
803
804 let payload = provider
805 .convert_to_zai_format(&request)
806 .expect("payload should be valid");
807 assert_eq!(
808 payload
809 .get("response_format")
810 .and_then(|v| v.get("type"))
811 .and_then(|v| v.as_str()),
812 Some("json_object")
813 );
814 }
815
816 #[test]
817 fn payload_keeps_json_mode_when_thinking_disabled() {
818 let provider = ZAIProvider::new("test-key".to_string());
819 let request = LLMRequest {
820 model: models::zai::GLM_5.to_string(),
821 messages: vec![Message::user("return json".to_string())],
822 output_format: Some(serde_json::json!({
823 "type": "object",
824 "properties": {
825 "sentiment": {"type": "string"}
826 }
827 })),
828 reasoning_effort: Some(ReasoningEffortLevel::None),
829 ..Default::default()
830 };
831
832 let payload = provider
833 .convert_to_zai_format(&request)
834 .expect("payload should be valid");
835 assert_eq!(
836 payload
837 .get("response_format")
838 .and_then(|v| v.get("type"))
839 .and_then(|v| v.as_str()),
840 Some("json_object")
841 );
842 assert_eq!(
843 payload
844 .get("thinking")
845 .and_then(|v| v.get("type"))
846 .and_then(|v| v.as_str()),
847 Some("disabled")
848 );
849 }
850}