Skip to main content

vtcode_llm/providers/
qwen.rs

1use serde_json::{Map, Value};
2use vtcode_config::constants::{env_vars, models, urls};
3
4use super::extract_reasoning_trace;
5use super::openai_compat::{OpenAiCompatCore, OpenAiCompatSpec, impl_openai_compat_provider};
6use crate::provider::{LLMError, LLMRequest};
7
8pub struct QwenSpec;
9
10fn qwen_reasoning(message: &Value, choice: &Value) -> Option<String> {
11    message
12        .get("reasoning_content")
13        .and_then(extract_reasoning_trace)
14        .or_else(|| choice.get("reasoning_content").and_then(extract_reasoning_trace))
15}
16
17impl OpenAiCompatSpec for QwenSpec {
18    const NAME: &'static str = "Qwen";
19    const KEY: &'static str = "qwen";
20    const API_KEY_ENV: &'static str = "QWEN_API_KEY";
21    const DEFAULT_MODEL: &'static str = models::qwen::DEFAULT_MODEL;
22    const DEFAULT_BASE_URL: &'static str = urls::QWEN_API_BASE;
23    const BASE_URL_ENV: Option<&'static str> = Some(env_vars::QWEN_BASE_URL);
24    const LISTED_MODELS: &'static [&'static str] = models::qwen::SUPPORTED_MODELS;
25    const VALIDATION_ALLOWLIST: Option<&'static [&'static str]> = Some(models::qwen::SUPPORTED_MODELS);
26
27    const STREAM_OPTIONS_INCLUDE_USAGE: bool = true;
28    const INCLUDE_USER_ID: bool = true;
29    const RESPONSE_REASONING_EXTRACTOR: Option<super::openai_compat::ReasoningExtractor> = Some(qwen_reasoning);
30
31    fn resolve_api_key(api_key: Option<String>) -> String {
32        api_key
33            .or_else(|| std::env::var("QWEN_API_KEY").ok().filter(|key| !key.trim().is_empty()))
34            .or_else(|| std::env::var("DASHSCOPE_API_KEY").ok().filter(|key| !key.trim().is_empty()))
35            .unwrap_or_default()
36    }
37
38    fn response_cache_metrics(core: &OpenAiCompatCore<Self>) -> bool {
39        core.prompt_cache_enabled
40    }
41
42    fn stream_cache_metrics(_core: &OpenAiCompatCore<Self>) -> bool {
43        true
44    }
45
46    fn insert_reasoning(
47        _core: &OpenAiCompatCore<Self>,
48        request: &LLMRequest,
49        payload: &mut Map<String, Value>,
50    ) -> Result<(), LLMError> {
51        if let Some(effort) = request.reasoning_effort {
52            let enable_thinking = effort != vtcode_config::types::ReasoningEffortLevel::None;
53            payload.insert("enable_thinking".to_owned(), Value::Bool(enable_thinking));
54        }
55        Ok(())
56    }
57}
58
59impl_openai_compat_provider!(QwenProvider, QwenSpec, {
60    fn supports_streaming(&self) -> bool {
61        true
62    }
63
64    fn supports_structured_output(&self, _model: &str) -> bool {
65        true
66    }
67
68    fn supports_reasoning(&self, _model: &str) -> bool {
69        self.core
70            .model_behavior
71            .as_ref()
72            .and_then(|b| b.model_supports_reasoning)
73            .unwrap_or(false)
74    }
75
76    fn supports_reasoning_effort(&self, _model: &str) -> bool {
77        self.core
78            .model_behavior
79            .as_ref()
80            .and_then(|b| b.model_supports_reasoning_effort)
81            .unwrap_or(false)
82    }
83
84    fn effective_context_size(&self, model: &str) -> usize {
85        let requested = if model.trim().is_empty() {
86            &self.core.model
87        } else {
88            model
89        };
90        crate::provider::catalog_context_window("qwen", requested, 131_072)
91    }
92});
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97    use crate::provider::{Message, ToolChoice};
98    use std::sync::Arc;
99    use vtcode_config::types::ReasoningEffortLevel;
100
101    fn provider() -> QwenProvider {
102        QwenProvider::from_config(
103            Some("test-key".to_string()),
104            Some("qwen3-max".to_string()),
105            Some("https://example.test/v1".to_string()),
106            None,
107            None,
108            None,
109            None,
110        )
111    }
112
113    fn base_request() -> LLMRequest {
114        LLMRequest {
115            messages: vec![Message::user("hello".to_string())].into(),
116            system_prompt: Some(Arc::from("system guidance")),
117            model: "qwen3-max".to_string(),
118            max_tokens: Some(512),
119            temperature: Some(0.5),
120            top_p: Some(0.25),
121            stream: true,
122            tool_choice: Some(ToolChoice::Auto),
123            metadata: Some(serde_json::json!({"user_id": "user-42"})),
124            ..Default::default()
125        }
126    }
127
128    #[test]
129    fn golden_payload_basic_shape() {
130        let payload = provider().core.convert_request(&base_request()).unwrap();
131
132        assert_eq!(payload["model"], "qwen3-max");
133        let messages = payload["messages"].as_array().unwrap();
134        assert_eq!(messages.len(), 2);
135        assert_eq!(messages[0]["role"], "system");
136        assert_eq!(messages[0]["content"], "system guidance");
137        assert_eq!(payload["max_tokens"], 512);
138        assert_eq!(payload["temperature"], 0.5);
139        assert_eq!(payload["top_p"], 0.25);
140        assert_eq!(payload["stream"], true);
141        assert_eq!(payload["stream_options"]["include_usage"], true);
142        assert_eq!(payload["tool_choice"], "auto");
143        assert_eq!(payload["user_id"], "user-42");
144        assert!(payload.get("enable_thinking").is_none());
145    }
146
147    #[test]
148    fn golden_payload_thinking_toggles_and_suppresses_sampling() {
149        let mut request = base_request();
150        request.reasoning_effort = Some(ReasoningEffortLevel::High);
151        let payload = provider().core.convert_request(&request).unwrap();
152        assert_eq!(payload["enable_thinking"], true);
153        assert!(payload.get("temperature").is_none());
154        assert!(payload.get("top_p").is_none());
155
156        let mut request = base_request();
157        request.reasoning_effort = Some(ReasoningEffortLevel::None);
158        let payload = provider().core.convert_request(&request).unwrap();
159        assert_eq!(payload["enable_thinking"], false);
160        assert_eq!(payload["temperature"], 0.5);
161        assert_eq!(payload["top_p"], 0.25);
162    }
163}