vtcode_llm/providers/
qwen.rs1use 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}