diff --git a/crates/openfang-runtime/src/drivers/claude_code.rs b/crates/openfang-runtime/src/drivers/claude_code.rs index 1cdfe3b44..c73ef6f74 100644 --- a/crates/openfang-runtime/src/drivers/claude_code.rs +++ b/crates/openfang-runtime/src/drivers/claude_code.rs @@ -125,6 +125,42 @@ impl ClaudeCodeDriver { } } + /// Build the CLI argument list for a completion request. + /// + /// Exposed as a testable method so unit tests can verify that + /// `--dangerously-skip-permissions`, `--model`, and output format flags + /// are set correctly. + fn build_args( + &self, + prompt: &str, + model_flag: Option<&str>, + streaming: bool, + ) -> Vec { + let mut args = vec![ + "-p".to_string(), + prompt.to_string(), + ]; + + if self.skip_permissions { + args.push("--dangerously-skip-permissions".to_string()); + } + + args.push("--output-format".to_string()); + if streaming { + args.push("stream-json".to_string()); + args.push("--verbose".to_string()); + } else { + args.push("json".to_string()); + } + + if let Some(model) = model_flag { + args.push("--model".to_string()); + args.push(model.to_string()); + } + + args + } + /// Apply security env filtering to a command. /// /// Instead of `env_clear()` (which breaks Node.js, NVM, SSL, proxies), @@ -199,19 +235,10 @@ impl LlmDriver for ClaudeCodeDriver { let prompt = Self::build_prompt(&request); let model_flag = Self::model_flag(&request.model); - let mut cmd = tokio::process::Command::new(&self.cli_path); - cmd.arg("-p") - .arg(&prompt) - .arg("--output-format") - .arg("json"); + let args = self.build_args(&prompt, model_flag.as_deref(), false); - if self.skip_permissions { - cmd.arg("--dangerously-skip-permissions"); - } - - if let Some(ref model) = model_flag { - cmd.arg("--model").arg(model); - } + let mut cmd = tokio::process::Command::new(&self.cli_path); + cmd.args(&args); Self::apply_env_filter(&mut cmd); @@ -302,20 +329,10 @@ impl LlmDriver for ClaudeCodeDriver { let prompt = Self::build_prompt(&request); let model_flag = Self::model_flag(&request.model); - let mut cmd = tokio::process::Command::new(&self.cli_path); - cmd.arg("-p") - .arg(&prompt) - .arg("--output-format") - .arg("stream-json") - .arg("--verbose"); + let args = self.build_args(&prompt, model_flag.as_deref(), true); - if self.skip_permissions { - cmd.arg("--dangerously-skip-permissions"); - } - - if let Some(ref model) = model_flag { - cmd.arg("--model").arg(model); - } + let mut cmd = tokio::process::Command::new(&self.cli_path); + cmd.args(&args); Self::apply_env_filter(&mut cmd); @@ -538,6 +555,34 @@ mod tests { assert!(!driver.skip_permissions); } + #[test] + fn test_build_args_with_skip_permissions() { + let driver = ClaudeCodeDriver::new(None, true); + let args = driver.build_args("hello", Some("opus"), false); + assert!(args.contains(&"--dangerously-skip-permissions".to_string()), + "should contain --dangerously-skip-permissions when skip_permissions=true"); + assert!(args.contains(&"json".to_string())); + assert!(args.contains(&"--model".to_string())); + assert!(args.contains(&"opus".to_string())); + } + + #[test] + fn test_build_args_without_skip_permissions() { + let driver = ClaudeCodeDriver::new(None, false); + let args = driver.build_args("hello", Some("sonnet"), false); + assert!(!args.contains(&"--dangerously-skip-permissions".to_string()), + "should NOT contain --dangerously-skip-permissions when skip_permissions=false"); + } + + #[test] + fn test_build_args_streaming() { + let driver = ClaudeCodeDriver::new(None, true); + let args = driver.build_args("hello", None, true); + assert!(args.contains(&"stream-json".to_string())); + assert!(args.contains(&"--verbose".to_string())); + assert!(!args.contains(&"--model".to_string()), "no model flag when model_flag is None"); + } + #[test] fn test_sensitive_env_list_coverage() { // Ensure all major provider keys are in the strip list diff --git a/crates/openfang-runtime/src/drivers/mod.rs b/crates/openfang-runtime/src/drivers/mod.rs index a6fd2f65f..113d9bca7 100644 --- a/crates/openfang-runtime/src/drivers/mod.rs +++ b/crates/openfang-runtime/src/drivers/mod.rs @@ -7,6 +7,7 @@ pub mod anthropic; pub mod claude_code; pub mod copilot; +pub mod qwen_code; pub mod fallback; pub mod gemini; pub mod openai; @@ -150,6 +151,11 @@ fn provider_defaults(provider: &str) -> Option { api_key_env: "", key_required: false, }), + "qwen-code" => Some(ProviderDefaults { + base_url: "", + api_key_env: "", + key_required: false, + }), "moonshot" | "kimi" | "kimi2" => Some(ProviderDefaults { base_url: MOONSHOT_BASE_URL, api_key_env: "MOONSHOT_API_KEY", @@ -309,6 +315,15 @@ pub fn create_driver(config: &DriverConfig) -> Result, LlmErr ))); } + // Qwen Code CLI — subprocess-based, no API key needed + if provider == "qwen-code" { + let cli_path = config.base_url.clone(); + return Ok(Arc::new(qwen_code::QwenCodeDriver::new( + cli_path, + config.skip_permissions, + ))); + } + // GitHub Copilot — wraps OpenAI-compatible driver with automatic token exchange. // The CopilotDriver exchanges the GitHub PAT for a Copilot API token on demand, // caches it, and refreshes when expired. @@ -411,7 +426,7 @@ pub fn create_driver(config: &DriverConfig) -> Result, LlmErr "Unknown provider '{}'. Supported: anthropic, gemini, openai, groq, openrouter, \ deepseek, together, mistral, fireworks, ollama, vllm, lmstudio, perplexity, \ cohere, ai21, cerebras, sambanova, huggingface, xai, replicate, github-copilot, \ - chutes, venice, codex, claude-code. Or set base_url for a custom OpenAI-compatible endpoint.", + chutes, venice, codex, claude-code, qwen-code. Or set base_url for a custom OpenAI-compatible endpoint.", provider ), }) @@ -486,6 +501,7 @@ pub fn known_providers() -> &'static [&'static str] { "venice", "codex", "claude-code", + "qwen-code", ] } @@ -587,7 +603,8 @@ mod tests { assert!(providers.contains(&"chutes")); assert!(providers.contains(&"codex")); assert!(providers.contains(&"claude-code")); - assert_eq!(providers.len(), 34); + assert!(providers.contains(&"qwen-code")); + assert_eq!(providers.len(), 35); } #[test] diff --git a/crates/openfang-runtime/src/drivers/qwen_code.rs b/crates/openfang-runtime/src/drivers/qwen_code.rs new file mode 100644 index 000000000..3734ebef9 --- /dev/null +++ b/crates/openfang-runtime/src/drivers/qwen_code.rs @@ -0,0 +1,652 @@ +//! Qwen Code CLI backend driver. +//! +//! Spawns the `qwen` CLI (Qwen Code) as a subprocess in print mode (`-p`), +//! which is non-interactive and handles its own authentication. +//! This allows users with Qwen Code installed to use it as an LLM provider +//! without needing a separate API key. + +use crate::llm_driver::{CompletionRequest, CompletionResponse, LlmDriver, LlmError, StreamEvent}; +use async_trait::async_trait; +use openfang_types::message::{ContentBlock, Role, StopReason, TokenUsage}; +use serde::Deserialize; +use tokio::io::AsyncBufReadExt; +use tracing::{debug, warn}; + +/// Environment variable names (and suffixes) to strip from the subprocess +/// to prevent leaking API keys from other providers. +const SENSITIVE_ENV_EXACT: &[&str] = &[ + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "GEMINI_API_KEY", + "GOOGLE_API_KEY", + "GROQ_API_KEY", + "DEEPSEEK_API_KEY", + "MISTRAL_API_KEY", + "TOGETHER_API_KEY", + "FIREWORKS_API_KEY", + "OPENROUTER_API_KEY", + "PERPLEXITY_API_KEY", + "COHERE_API_KEY", + "AI21_API_KEY", + "CEREBRAS_API_KEY", + "SAMBANOVA_API_KEY", + "HUGGINGFACE_API_KEY", + "XAI_API_KEY", + "REPLICATE_API_TOKEN", + "BRAVE_API_KEY", + "TAVILY_API_KEY", + "ELEVENLABS_API_KEY", +]; + +/// Suffixes that indicate a secret — remove any env var ending with these +/// unless it starts with `QWEN_`. +const SENSITIVE_SUFFIXES: &[&str] = &["_SECRET", "_TOKEN", "_PASSWORD"]; + +/// LLM driver that delegates to the Qwen Code CLI. +pub struct QwenCodeDriver { + cli_path: String, + skip_permissions: bool, +} + +impl QwenCodeDriver { + /// Create a new Qwen Code driver. + /// + /// `cli_path` overrides the CLI binary path; defaults to `"qwen"` on PATH. + /// `skip_permissions` adds `--yolo` to the spawned command so that the CLI + /// runs non-interactively (required for daemon mode). + pub fn new(cli_path: Option, skip_permissions: bool) -> Self { + if skip_permissions { + warn!( + "Qwen Code driver: --yolo enabled. \ + The CLI will not prompt for tool approvals. \ + OpenFang's own capability/RBAC system enforces access control." + ); + } + + Self { + cli_path: cli_path + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "qwen".to_string()), + skip_permissions, + } + } + + /// Detect if the Qwen Code CLI is available on PATH. + pub fn detect() -> Option { + let output = std::process::Command::new("qwen") + .arg("--version") + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .output() + .ok()?; + + if output.status.success() { + Some(String::from_utf8_lossy(&output.stdout).trim().to_string()) + } else { + None + } + } + + /// Build a text prompt from the completion request messages. + fn build_prompt(request: &CompletionRequest) -> String { + let mut parts = Vec::new(); + + if let Some(ref sys) = request.system { + parts.push(format!("[System]\n{sys}")); + } + + for msg in &request.messages { + let role_label = match msg.role { + Role::User => "User", + Role::Assistant => "Assistant", + Role::System => "System", + }; + let text = msg.content.text_content(); + if !text.is_empty() { + parts.push(format!("[{role_label}]\n{text}")); + } + } + + parts.join("\n\n") + } + + /// Map a model ID like "qwen-code/qwen3-coder" to CLI --model flag value. + fn model_flag(model: &str) -> Option { + let stripped = model + .strip_prefix("qwen-code/") + .unwrap_or(model); + match stripped { + "qwen3-coder" => Some("qwen3-coder".to_string()), + "qwen-coder-plus" => Some("qwen-coder-plus".to_string()), + "qwq-32b" => Some("qwq-32b".to_string()), + _ => Some(stripped.to_string()), + } + } + + /// Build the CLI argument list for a completion request. + /// + /// Exposed as a testable method so unit tests can verify that `--yolo`, + /// `--model`, and output format flags are set correctly. + fn build_args( + &self, + prompt: &str, + model_flag: Option<&str>, + streaming: bool, + ) -> Vec { + let mut args = vec![ + "-p".to_string(), + prompt.to_string(), + "--output-format".to_string(), + if streaming { + "stream-json".to_string() + } else { + "json".to_string() + }, + ]; + + if self.skip_permissions { + args.push("--yolo".to_string()); + } + + if let Some(model) = model_flag { + args.push("--model".to_string()); + args.push(model.to_string()); + } + + args + } + + /// Apply security env filtering to a command. + /// + /// Instead of `env_clear()` (which breaks Node.js, NVM, SSL, proxies), + /// we keep the full environment and only remove known sensitive API keys + /// from other LLM providers. + fn apply_env_filter(cmd: &mut tokio::process::Command) { + for key in SENSITIVE_ENV_EXACT { + cmd.env_remove(key); + } + // Remove any env var with a sensitive suffix, unless it's QWEN_* + for (key, _) in std::env::vars() { + if key.starts_with("QWEN_") { + continue; + } + let upper = key.to_uppercase(); + for suffix in SENSITIVE_SUFFIXES { + if upper.ends_with(suffix) { + cmd.env_remove(&key); + break; + } + } + } + } +} + +/// JSON output from `qwen -p --output-format json`. +/// +/// The CLI may return the response text in different fields depending on +/// version: `result`, `content`, or `text`. We try all three. +#[derive(Debug, Deserialize)] +struct QwenJsonOutput { + result: Option, + #[serde(default)] + content: Option, + #[serde(default)] + text: Option, + #[serde(default)] + usage: Option, + #[serde(default)] + #[allow(dead_code)] + cost_usd: Option, +} + +/// Usage stats from Qwen CLI JSON output. +#[derive(Debug, Deserialize, Default)] +struct QwenUsage { + #[serde(default)] + input_tokens: u64, + #[serde(default)] + output_tokens: u64, +} + +/// Stream JSON event from `qwen -p --output-format stream-json`. +#[derive(Debug, Deserialize)] +struct QwenStreamEvent { + #[serde(default)] + r#type: String, + #[serde(default)] + content: Option, + #[serde(default)] + result: Option, + #[serde(default)] + usage: Option, +} + +#[async_trait] +impl LlmDriver for QwenCodeDriver { + async fn complete( + &self, + request: CompletionRequest, + ) -> Result { + let prompt = Self::build_prompt(&request); + let model_flag = Self::model_flag(&request.model); + + let args = self.build_args(&prompt, model_flag.as_deref(), false); + + let mut cmd = tokio::process::Command::new(&self.cli_path); + cmd.args(&args); + + Self::apply_env_filter(&mut cmd); + + cmd.stdout(std::process::Stdio::piped()); + cmd.stderr(std::process::Stdio::piped()); + + debug!(cli = %self.cli_path, skip_permissions = self.skip_permissions, "Spawning Qwen Code CLI"); + + let output = cmd + .output() + .await + .map_err(|e| LlmError::Http(format!( + "Qwen Code CLI not found or failed to start ({}). \ + Install: npm install -g @qwen-code/qwen-code", + e + )))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let detail = if !stderr.is_empty() { &stderr } else { &stdout }; + let code = output.status.code().unwrap_or(1); + + // Provide actionable error messages + let message = if detail.contains("not authenticated") + || detail.contains("auth") + || detail.contains("login") + || detail.contains("credentials") + { + format!( + "Qwen Code CLI is not authenticated. Run: qwen --auth-type qwen-oauth\nDetail: {detail}" + ) + } else if detail.contains("permission") + || detail.contains("--yolo") + { + format!( + "Qwen Code CLI requires permissions acceptance. \ + Run: qwen --yolo (once to accept)\nDetail: {detail}" + ) + } else { + format!("Qwen Code CLI exited with code {code}: {detail}") + }; + + return Err(LlmError::Api { + status: code as u16, + message, + }); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + + // Try JSON parse first + if let Ok(parsed) = serde_json::from_str::(&stdout) { + let text = parsed.result + .or(parsed.content) + .or(parsed.text) + .unwrap_or_default(); + let usage = parsed.usage.unwrap_or_default(); + return Ok(CompletionResponse { + content: vec![ContentBlock::Text { text: text.clone(), provider_metadata: None }], + stop_reason: StopReason::EndTurn, + tool_calls: Vec::new(), + usage: TokenUsage { + input_tokens: usage.input_tokens, + output_tokens: usage.output_tokens, + }, + }); + } + + // Fallback: treat entire stdout as plain text + let text = stdout.trim().to_string(); + Ok(CompletionResponse { + content: vec![ContentBlock::Text { text, provider_metadata: None }], + stop_reason: StopReason::EndTurn, + tool_calls: Vec::new(), + usage: TokenUsage { + input_tokens: 0, + output_tokens: 0, + }, + }) + } + + async fn stream( + &self, + request: CompletionRequest, + tx: tokio::sync::mpsc::Sender, + ) -> Result { + let prompt = Self::build_prompt(&request); + let model_flag = Self::model_flag(&request.model); + + let args = self.build_args(&prompt, model_flag.as_deref(), true); + + let mut cmd = tokio::process::Command::new(&self.cli_path); + cmd.args(&args); + + Self::apply_env_filter(&mut cmd); + + cmd.stdout(std::process::Stdio::piped()); + cmd.stderr(std::process::Stdio::piped()); + + debug!(cli = %self.cli_path, skip_permissions = self.skip_permissions, "Spawning Qwen Code CLI (streaming)"); + + let mut child = cmd + .spawn() + .map_err(|e| LlmError::Http(format!( + "Qwen Code CLI not found or failed to start ({}). \ + Install: npm install -g @qwen-code/qwen-code", + e + )))?; + + let stdout = child + .stdout + .take() + .ok_or_else(|| LlmError::Http("No stdout from qwen CLI".to_string()))?; + + let reader = tokio::io::BufReader::new(stdout); + let mut lines = reader.lines(); + + let mut full_text = String::new(); + let mut final_usage = TokenUsage { + input_tokens: 0, + output_tokens: 0, + }; + + while let Ok(Some(line)) = lines.next_line().await { + if line.trim().is_empty() { + continue; + } + + match serde_json::from_str::(&line) { + Ok(event) => { + match event.r#type.as_str() { + "content" | "text" | "assistant" | "content_block_delta" => { + if let Some(ref content) = event.content { + full_text.push_str(content); + let _ = tx + .send(StreamEvent::TextDelta { + text: content.clone(), + }) + .await; + } + } + "result" | "done" | "complete" => { + if let Some(ref result) = event.result { + if full_text.is_empty() { + full_text = result.clone(); + let _ = tx + .send(StreamEvent::TextDelta { + text: result.clone(), + }) + .await; + } + } + if let Some(usage) = event.usage { + final_usage = TokenUsage { + input_tokens: usage.input_tokens, + output_tokens: usage.output_tokens, + }; + } + } + _ => { + // Unknown event type — try content field as fallback + if let Some(ref content) = event.content { + full_text.push_str(content); + let _ = tx + .send(StreamEvent::TextDelta { + text: content.clone(), + }) + .await; + } + } + } + } + Err(e) => { + // Not valid JSON — treat as raw text + warn!(line = %line, error = %e, "Non-JSON line from Qwen CLI"); + full_text.push_str(&line); + let _ = tx + .send(StreamEvent::TextDelta { text: line }) + .await; + } + } + } + + // Wait for process to finish + let status = child + .wait() + .await + .map_err(|e| LlmError::Http(format!("Qwen CLI wait failed: {e}")))?; + + if !status.success() { + warn!(code = ?status.code(), "Qwen CLI exited with error"); + } + + let _ = tx + .send(StreamEvent::ContentComplete { + stop_reason: StopReason::EndTurn, + usage: final_usage, + }) + .await; + + Ok(CompletionResponse { + content: vec![ContentBlock::Text { text: full_text, provider_metadata: None }], + stop_reason: StopReason::EndTurn, + tool_calls: Vec::new(), + usage: final_usage, + }) + } +} + +/// Check if the Qwen Code CLI is available and authenticated. +pub fn qwen_code_available() -> bool { + QwenCodeDriver::detect().is_some() + || qwen_credentials_exist() +} + +/// Check if Qwen credentials exist. +/// +/// Qwen Code stores session/credentials in `~/.qwen/` directory. +fn qwen_credentials_exist() -> bool { + if let Some(home) = home_dir() { + let qwen_dir = home.join(".qwen"); + qwen_dir.exists() + } else { + false + } +} + +/// Cross-platform home directory. +fn home_dir() -> Option { + #[cfg(target_os = "windows")] + { + std::env::var("USERPROFILE").ok().map(std::path::PathBuf::from) + } + #[cfg(not(target_os = "windows"))] + { + std::env::var("HOME").ok().map(std::path::PathBuf::from) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_build_prompt_simple() { + use openfang_types::message::{Message, MessageContent}; + + let request = CompletionRequest { + model: "qwen-code/qwen3-coder".to_string(), + messages: vec![Message { + role: Role::User, + content: MessageContent::text("Hello"), + }], + tools: vec![], + max_tokens: 1024, + temperature: 0.7, + system: Some("You are helpful.".to_string()), + thinking: None, + }; + + let prompt = QwenCodeDriver::build_prompt(&request); + assert!(prompt.contains("[System]")); + assert!(prompt.contains("You are helpful.")); + assert!(prompt.contains("[User]")); + assert!(prompt.contains("Hello")); + } + + #[test] + fn test_build_prompt_multi_turn() { + use openfang_types::message::{Message, MessageContent}; + + let request = CompletionRequest { + model: "qwen-code/qwen3-coder".to_string(), + messages: vec![ + Message { + role: Role::User, + content: MessageContent::text("What is 2+2?"), + }, + Message { + role: Role::Assistant, + content: MessageContent::text("4"), + }, + Message { + role: Role::User, + content: MessageContent::text("And 3+3?"), + }, + ], + tools: vec![], + max_tokens: 1024, + temperature: 0.7, + system: None, + thinking: None, + }; + + let prompt = QwenCodeDriver::build_prompt(&request); + assert!(prompt.contains("[User]\nWhat is 2+2?")); + assert!(prompt.contains("[Assistant]\n4")); + assert!(prompt.contains("[User]\nAnd 3+3?")); + } + + #[test] + fn test_model_flag_mapping() { + assert_eq!( + QwenCodeDriver::model_flag("qwen-code/qwen3-coder"), + Some("qwen3-coder".to_string()) + ); + assert_eq!( + QwenCodeDriver::model_flag("qwen-code/qwen-coder-plus"), + Some("qwen-coder-plus".to_string()) + ); + assert_eq!( + QwenCodeDriver::model_flag("qwen-code/qwq-32b"), + Some("qwq-32b".to_string()) + ); + assert_eq!( + QwenCodeDriver::model_flag("custom-model"), + Some("custom-model".to_string()) + ); + } + + #[test] + fn test_new_defaults_to_qwen() { + let driver = QwenCodeDriver::new(None, true); + assert_eq!(driver.cli_path, "qwen"); + assert!(driver.skip_permissions); + } + + #[test] + fn test_new_with_custom_path() { + let driver = QwenCodeDriver::new(Some("/usr/local/bin/qwen".to_string()), true); + assert_eq!(driver.cli_path, "/usr/local/bin/qwen"); + } + + #[test] + fn test_new_with_empty_path() { + let driver = QwenCodeDriver::new(Some(String::new()), true); + assert_eq!(driver.cli_path, "qwen"); + } + + #[test] + fn test_skip_permissions_disabled() { + let driver = QwenCodeDriver::new(None, false); + assert!(!driver.skip_permissions); + } + + #[test] + fn test_build_args_with_yolo() { + let driver = QwenCodeDriver::new(None, true); + let args = driver.build_args("hello", Some("qwen3-coder"), false); + assert!(args.contains(&"--yolo".to_string()), "should contain --yolo when skip_permissions=true"); + assert!(args.contains(&"json".to_string())); + assert!(args.contains(&"--model".to_string())); + assert!(args.contains(&"qwen3-coder".to_string())); + } + + #[test] + fn test_build_args_without_yolo() { + let driver = QwenCodeDriver::new(None, false); + let args = driver.build_args("hello", Some("qwen3-coder"), false); + assert!(!args.contains(&"--yolo".to_string()), "should NOT contain --yolo when skip_permissions=false"); + } + + #[test] + fn test_build_args_streaming() { + let driver = QwenCodeDriver::new(None, true); + let args = driver.build_args("hello", None, true); + assert!(args.contains(&"stream-json".to_string())); + assert!(!args.contains(&"json".to_string()) || args.contains(&"stream-json".to_string())); + assert!(!args.contains(&"--model".to_string()), "no model flag when model_flag is None"); + } + + #[test] + fn test_sensitive_env_list_coverage() { + assert!(SENSITIVE_ENV_EXACT.contains(&"OPENAI_API_KEY")); + assert!(SENSITIVE_ENV_EXACT.contains(&"ANTHROPIC_API_KEY")); + assert!(SENSITIVE_ENV_EXACT.contains(&"GEMINI_API_KEY")); + assert!(SENSITIVE_ENV_EXACT.contains(&"GROQ_API_KEY")); + assert!(SENSITIVE_ENV_EXACT.contains(&"DEEPSEEK_API_KEY")); + } + + #[test] + fn test_json_output_parsing() { + let json = r#"{"result":"Hello world","usage":{"input_tokens":10,"output_tokens":5}}"#; + let parsed: QwenJsonOutput = serde_json::from_str(json).unwrap(); + assert_eq!(parsed.result.unwrap(), "Hello world"); + assert_eq!(parsed.usage.unwrap().input_tokens, 10); + } + + #[test] + fn test_json_output_content_fallback() { + let json = r#"{"content":"Fallback text"}"#; + let parsed: QwenJsonOutput = serde_json::from_str(json).unwrap(); + assert!(parsed.result.is_none()); + assert_eq!(parsed.content.unwrap(), "Fallback text"); + } + + #[test] + fn test_stream_event_parsing() { + let json = r#"{"type":"content","content":"chunk"}"#; + let event: QwenStreamEvent = serde_json::from_str(json).unwrap(); + assert_eq!(event.r#type, "content"); + assert_eq!(event.content.unwrap(), "chunk"); + } + + #[test] + fn test_stream_event_result() { + let json = r#"{"type":"result","result":"final answer","usage":{"input_tokens":100,"output_tokens":50}}"#; + let event: QwenStreamEvent = serde_json::from_str(json).unwrap(); + assert_eq!(event.r#type, "result"); + assert_eq!(event.result.unwrap(), "final answer"); + let usage = event.usage.unwrap(); + assert_eq!(usage.input_tokens, 100); + assert_eq!(usage.output_tokens, 50); + } +} diff --git a/crates/openfang-runtime/src/model_catalog.rs b/crates/openfang-runtime/src/model_catalog.rs index 8822c460e..49e7ff7b3 100644 --- a/crates/openfang-runtime/src/model_catalog.rs +++ b/crates/openfang-runtime/src/model_catalog.rs @@ -68,6 +68,19 @@ impl ModelCatalog { continue; } + // Qwen Code: detect CLI installation + authentication + if provider.id == "qwen-code" { + let cli_installed = crate::drivers::qwen_code::QwenCodeDriver::detect().is_some(); + if cli_installed && crate::drivers::qwen_code::qwen_code_available() { + provider.auth_status = AuthStatus::Configured; + } else if cli_installed { + provider.auth_status = AuthStatus::Missing; + } else { + provider.auth_status = AuthStatus::NotRequired; + } + continue; + } + if !provider.key_required { provider.auth_status = AuthStatus::NotRequired; continue; @@ -755,6 +768,16 @@ fn builtin_providers() -> Vec { auth_status: AuthStatus::NotRequired, model_count: 0, }, + // ── Qwen Code CLI ───────────────────────────────────────── + ProviderInfo { + id: "qwen-code".into(), + display_name: "Qwen Code".into(), + api_key_env: String::new(), + base_url: String::new(), + key_required: false, + auth_status: AuthStatus::NotRequired, + model_count: 0, + }, ] } @@ -828,6 +851,11 @@ fn builtin_aliases() -> HashMap { ("claude-code-opus", "claude-code/opus"), ("claude-code-sonnet", "claude-code/sonnet"), ("claude-code-haiku", "claude-code/haiku"), + // Qwen Code aliases + ("qwen-code", "qwen-code/qwen3-coder"), + ("qwen-code-qwen3", "qwen-code/qwen3-coder"), + ("qwen-code-plus", "qwen-code/qwen-coder-plus"), + ("qwen-code-qwq", "qwen-code/qwq-32b"), ]; pairs .into_iter() @@ -3416,6 +3444,51 @@ fn builtin_models() -> Vec { aliases: vec!["claude-code-haiku".into()], }, // ══════════════════════════════════════════════════════════════ + // Qwen Code CLI (3) — subprocess-based + // ══════════════════════════════════════════════════════════════ + ModelCatalogEntry { + id: "qwen-code/qwen3-coder".into(), + display_name: "Qwen3 Coder (CLI)".into(), + provider: "qwen-code".into(), + tier: ModelTier::Smart, + context_window: 131_072, + max_output_tokens: 65_536, + input_cost_per_m: 0.0, + output_cost_per_m: 0.0, + supports_tools: false, + supports_vision: false, + supports_streaming: true, + aliases: vec!["qwen-code".into(), "qwen-code-qwen3".into()], + }, + ModelCatalogEntry { + id: "qwen-code/qwen-coder-plus".into(), + display_name: "Qwen Coder Plus (CLI)".into(), + provider: "qwen-code".into(), + tier: ModelTier::Frontier, + context_window: 131_072, + max_output_tokens: 65_536, + input_cost_per_m: 0.0, + output_cost_per_m: 0.0, + supports_tools: false, + supports_vision: false, + supports_streaming: true, + aliases: vec!["qwen-code-plus".into()], + }, + ModelCatalogEntry { + id: "qwen-code/qwq-32b".into(), + display_name: "QwQ 32B (CLI)".into(), + provider: "qwen-code".into(), + tier: ModelTier::Balanced, + context_window: 131_072, + max_output_tokens: 65_536, + input_cost_per_m: 0.0, + output_cost_per_m: 0.0, + supports_tools: false, + supports_vision: false, + supports_streaming: true, + aliases: vec!["qwen-code-qwq".into()], + }, + // ══════════════════════════════════════════════════════════════ // Chutes.ai (5) // ══════════════════════════════════════════════════════════════ ModelCatalogEntry { @@ -3917,4 +3990,29 @@ mod tests { let entry = catalog.find_model("claude-code").unwrap(); assert_eq!(entry.id, "claude-code/sonnet"); } + + #[test] + fn test_qwen_code_provider() { + let catalog = ModelCatalog::new(); + let qc = catalog.get_provider("qwen-code").unwrap(); + assert_eq!(qc.display_name, "Qwen Code"); + assert!(!qc.key_required); + } + + #[test] + fn test_qwen_code_models() { + let catalog = ModelCatalog::new(); + let models = catalog.models_by_provider("qwen-code"); + assert_eq!(models.len(), 3); + assert!(models.iter().any(|m| m.id == "qwen-code/qwen3-coder")); + assert!(models.iter().any(|m| m.id == "qwen-code/qwen-coder-plus")); + assert!(models.iter().any(|m| m.id == "qwen-code/qwq-32b")); + } + + #[test] + fn test_qwen_code_aliases() { + let catalog = ModelCatalog::new(); + let entry = catalog.find_model("qwen-code").unwrap(); + assert_eq!(entry.id, "qwen-code/qwen3-coder"); + } }