Skip to main content

vtcode_core/llm/providers/
zai.rs

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        // Codex-inspired robustness: Setting model_supports_reasoning to false
252        // does NOT disable it for known reasoning models.
253        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        // Same robustness logic for reasoning effort
263        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}