Skip to main content

robit_ai/
config.rs

1//! Configuration loading for config.toml.
2//!
3//! Loads a single unified config file from:
4//!   1. `cwd/.robit/config.toml` (project-local, highest priority)
5//!   2. `~/.robit/config.toml`   (global fallback)
6//!
7//! Configuration format uses a providers + models structure:
8//! ```toml
9//! default_model = "deepseek/deepseek-chat"
10//!
11//! [providers.deepseek]
12//! name = "DeepSeek"
13//! base_url = "https://api.deepseek.com/v1"
14//! api_key = "${DEEPSEEK_API_KEY}"
15//!
16//! [[providers.deepseek.models]]
17//! id = "deepseek-chat"
18//! context_window = 65536
19//! ```
20//!
21//! Environment variable substitution is supported in `api_key` fields via `${ENV_VAR}` syntax.
22
23use serde::Deserialize;
24use std::collections::HashMap;
25use std::path::PathBuf;
26
27use crate::error::LlmError;
28
29// ============================================================================
30// config.toml structures
31// ============================================================================
32
33/// Top-level config.toml configuration.
34#[derive(Debug, Deserialize)]
35pub struct RobitConfig {
36    /// Default model in "provider/model" format (e.g. "deepseek/deepseek-chat").
37    pub default_model: Option<String>,
38    /// Provider definitions keyed by provider name.
39    pub providers: HashMap<String, ProviderConfig>,
40    /// Application settings.
41    pub app: Option<AppConfig>,
42    /// Communication channel configurations (QQ Bot, Feishu, etc.).
43    #[serde(default)]
44    pub channels: Option<ChannelsConfig>,
45    /// Default image generation model in "provider/model" format
46    /// (e.g. "wanxiang/wan2.7-image-pro"). Only effective when
47    /// `image_providers` is also configured.
48    pub default_image_model: Option<String>,
49    /// Image generation provider definitions, keyed by provider name.
50    #[serde(default)]
51    pub image_providers: HashMap<String, ImageProviderConfig>,
52}
53
54/// A single LLM provider (one API endpoint with multiple models).
55#[derive(Debug, Deserialize)]
56pub struct ProviderConfig {
57    /// Display name for the provider (optional).
58    pub name: Option<String>,
59    /// API base URL (must be OpenAI-compatible).
60    pub base_url: String,
61    /// API key (supports `${ENV_VAR}` substitution).
62    pub api_key: String,
63    /// Available models under this provider.
64    pub models: Vec<ModelConfig>,
65}
66
67/// A single model definition within a provider.
68#[derive(Debug, Deserialize)]
69pub struct ModelConfig {
70    /// Model ID used in API calls (e.g. "deepseek-chat").
71    pub id: String,
72    /// Display name (optional).
73    pub name: Option<String>,
74    /// Context window size in tokens (optional).
75    pub context_window: Option<u64>,
76    /// Maximum output tokens (optional).
77    pub max_output_tokens: Option<u64>,
78    /// Sampling temperature (optional, runtime parameter).
79    pub temperature: Option<f32>,
80    /// Maximum completion tokens (optional, runtime parameter).
81    pub max_tokens: Option<u32>,
82    /// Whether this model supports image inputs (optional, default false).
83    pub supports_images: Option<bool>,
84    /// Whether this model supports tool calling (optional, default false).
85    pub supports_tools: Option<bool>,
86}
87
88// ============================================================================
89// Image generation provider config
90// ============================================================================
91
92/// Protocol used by an image generation provider.
93#[derive(Debug, Deserialize, Clone, PartialEq, Eq)]
94#[serde(rename_all = "lowercase")]
95pub enum ImageProtocol {
96    /// OpenAI-compatible Images API (`POST /images/generations`).
97    Openai,
98    /// DashScope native protocol (Wanxiang, supports sync/async modes).
99    Dashscope,
100}
101
102impl Default for ImageProtocol {
103    fn default() -> Self {
104        Self::Openai
105    }
106}
107
108/// Call mode for DashScope image generation.
109#[derive(Debug, Deserialize, Clone, PartialEq, Eq)]
110#[serde(rename_all = "lowercase")]
111pub enum ImageCallMode {
112    /// Synchronous call: one request returns the result directly.
113    Sync,
114    /// Asynchronous call: submit a task, then poll until completion.
115    Async,
116}
117
118impl Default for ImageCallMode {
119    fn default() -> Self {
120        Self::Sync
121    }
122}
123
124/// A single image generation model definition.
125#[derive(Debug, Deserialize, Clone)]
126pub struct ImageModelConfig {
127    /// Model ID used in API calls (e.g. "wan2.7-image-pro").
128    pub id: String,
129    /// Display name (optional).
130    pub name: Option<String>,
131}
132
133/// An image generation provider (one API endpoint with multiple models).
134#[derive(Debug, Deserialize, Clone)]
135pub struct ImageProviderConfig {
136    /// Display name for the provider (optional).
137    pub name: Option<String>,
138    /// API base URL. For DashScope this includes the `/api/v1` prefix
139    /// (e.g. `https://dashscope.aliyuncs.com/api/v1`). For OpenAI-compatible
140    /// providers, include the `/v1` prefix (e.g. `https://api.openai.com/v1`).
141    /// This mirrors how chat `providers` configure `base_url` - the client
142    /// only appends the endpoint path, not a version prefix.
143    pub base_url: String,
144    /// API key (supports `${ENV_VAR}` substitution).
145    pub api_key: String,
146    /// Protocol used by this provider (default: openai).
147    #[serde(default)]
148    pub protocol: ImageProtocol,
149    /// Call mode, only effective for `Dashscope` protocol (default: sync).
150    #[serde(default)]
151    pub mode: ImageCallMode,
152    /// Available models under this provider.
153    pub models: Vec<ImageModelConfig>,
154    /// Polling interval in seconds for async mode (default: 3).
155    #[serde(default = "default_poll_interval")]
156    pub poll_interval_secs: u64,
157    /// Total polling timeout in seconds for async mode (default: 300).
158    #[serde(default = "default_poll_timeout")]
159    pub poll_timeout_secs: u64,
160}
161
162fn default_poll_interval() -> u64 {
163    3
164}
165
166fn default_poll_timeout() -> u64 {
167    300
168}
169
170// ============================================================================
171// Application config (unchanged from previous version)
172// ============================================================================
173
174#[derive(Debug, Deserialize, Default)]
175pub struct AppConfig {
176    pub log_level: Option<String>,
177    /// Whether to log to file (default: false).
178    pub log_file: Option<bool>,
179    pub max_steps: Option<usize>,
180    pub enabled_tools: Option<Vec<String>>,
181    pub enabled_skills: Option<Vec<String>>,
182    pub context: Option<ContextConfig>,
183    pub retry: Option<RetryConfig>,
184    pub auto_approve: Option<bool>,
185    pub global_storage: Option<bool>,
186    /// Bot platform settings (shared across Bot frontends).
187    pub bot: Option<BotConfig>,
188}
189
190#[derive(Debug, Clone, Deserialize)]
191pub struct ContextConfig {
192    pub max_output_lines: Option<usize>,
193    pub max_output_bytes: Option<usize>,
194    pub reserve_ratio: Option<f32>,
195    /// Fraction of max_tokens at which truncation triggers (default 0.7).
196    /// Lower = earlier truncation, more headroom for estimation errors.
197    pub truncation_ratio: Option<f32>,
198    /// Minimum conversation rounds to keep after truncation (default 3).
199    /// Prevents losing all recent context when truncation is aggressive.
200    pub min_keep_rounds: Option<usize>,
201    /// Safety multiplier applied to token estimates (default 1.3).
202    /// Compensates for heuristic underestimation vs actual tokenizer counts.
203    pub token_safety_margin: Option<f32>,
204    /// Token threshold for triggering compression (default 5000).
205    /// Only compress when removed messages exceed this token count.
206    pub compression_token_threshold: Option<usize>,
207    /// Enable/disable context compression (default true).
208    pub compression_enabled: Option<bool>,
209    /// Maximum tool calls allowed per turn before forcing early termination (default 30).
210    /// Prevents a single user turn from exploding the context with excessive tool calls.
211    pub max_tool_calls_per_turn: Option<usize>,
212    /// Enable progressive segmented compression (default true).
213    /// When false, falls back to the old single-shot truncation + one summary behavior.
214    pub progressive_compression: Option<bool>,
215    /// Number of full conversation rounds per summary segment (default 3).
216    /// Each compression converts the oldest N full rounds into one summary segment.
217    pub rounds_per_summary: Option<usize>,
218    /// Maximum number of summary segments to keep (default 5).
219    /// When exceeded, the oldest segments are merged (or discarded if merge limit reached).
220    pub max_summary_segments: Option<usize>,
221    /// Number of segments to merge each time (default 2).
222    pub merge_count: Option<usize>,
223    /// Maximum times a single summary segment may be merged before being discarded (default 2).
224    /// Controls information distortion — each merge loses detail; discard when limit is hit.
225    pub max_merges_per_segment: Option<usize>,
226}
227
228#[derive(Debug, Deserialize)]
229pub struct RetryConfig {
230    pub max_retries: Option<u32>,
231    pub initial_backoff_ms: Option<u64>,
232    pub max_backoff_ms: Option<u64>,
233}
234
235// ============================================================================
236// Communication channels config (QQ Bot, Feishu, etc.)
237// ============================================================================
238
239/// Communication channel configurations, separate from LLM `providers`.
240#[derive(Debug, Deserialize, Default)]
241pub struct ChannelsConfig {
242    /// QQ Official Bot channel.
243    pub qq_bot: Option<QqBotConfig>,
244}
245
246/// QQ Official Bot credentials (from `[channels.qq_bot]`).
247#[derive(Debug, Deserialize, Clone)]
248pub struct QqBotConfig {
249    pub app_id: String,
250    pub app_secret: String,
251}
252
253// ============================================================================
254// Bot platform app config
255// ============================================================================
256
257/// Shared Bot platform settings under `[app.bot]`.
258#[derive(Debug, Deserialize, Default)]
259pub struct BotConfig {
260    /// Timeout (seconds) for waiting on a tool confirmation reply.
261    pub confirm_timeout_secs: Option<u64>,
262    /// Idle session expiry (minutes) before cleanup.
263    pub session_timeout_minutes: Option<u64>,
264    /// Custom confirm/reject keywords.
265    pub confirm_keywords: Option<ConfirmKeywordsConfig>,
266}
267
268/// Confirm/reject keyword lists for inline tool confirmation.
269#[derive(Debug, Deserialize, Clone, Default)]
270pub struct ConfirmKeywordsConfig {
271    pub approve: Option<Vec<String>>,
272    pub reject: Option<Vec<String>>,
273}
274
275// ============================================================================
276// Resolved model reference
277// ============================================================================
278
279/// A fully resolved model ready for client construction.
280///
281/// Merges provider-level settings (base_url, api_key) with model-level
282/// settings (context_window, temperature, etc).
283#[derive(Debug, Clone)]
284pub struct ResolvedModel {
285    pub profile_name: String,
286    pub model_id: String,
287    pub base_url: String,
288    pub api_key: String,
289    pub max_tokens: Option<u32>,
290    pub temperature: Option<f32>,
291    pub context_window: Option<u64>,
292    /// Whether this model supports image inputs.
293    pub supports_images: bool,
294    /// Whether this model supports tool calling.
295    pub supports_tools: bool,
296}
297
298// ============================================================================
299// Loader
300// ============================================================================
301
302/// Returns the ~/.robit/ directory path.
303fn robit_home() -> Result<PathBuf, LlmError> {
304    let home = dirs::home_dir()
305        .ok_or_else(|| LlmError::ConfigError("Cannot determine home directory".to_string()))?;
306    Ok(home.join(".robit"))
307}
308
309/// Replace `${ENV_VAR}` patterns with actual environment variable values.
310fn resolve_env_var(value: &str) -> String {
311    if let Some(var_name) = value.strip_prefix("${").and_then(|s| s.strip_suffix('}')) {
312        std::env::var(var_name).unwrap_or_else(|_| value.to_string())
313    } else {
314        value.to_string()
315    }
316}
317
318/// Load and parse the config.toml config file.
319///
320/// Automatically loads .env files before resolving `${ENV_VAR}` patterns:
321///   1. `~/.robit/.env` (global, lower priority)
322///   2. `workdir/.robit/.env` (project-local, higher priority)
323///
324/// Search order for config.toml:
325///   1. `workdir/.robit/config.toml` (project-local, if workdir provided)
326///   2. `cwd/.robit/config.toml` (project-local, if workdir not provided)
327///   3. `~/.robit/config.toml`   (global fallback)
328pub fn load_config(workdir: Option<&std::path::Path>) -> Result<RobitConfig, LlmError> {
329    // Load .env first so ${ENV_VAR} substitutions work
330    load_env_from(workdir);
331
332    let path = find_config_path(workdir)?;
333
334    let content = std::fs::read_to_string(&path)
335        .map_err(|e| LlmError::ConfigError(format!("Failed to read {}: {}", path.display(), e)))?;
336
337    let mut config: RobitConfig = toml::from_str(&content)
338        .map_err(|e| LlmError::ConfigError(format!("Failed to parse config.toml: {}", e)))?;
339
340    // Resolve environment variables in api_key fields
341    for provider in config.providers.values_mut() {
342        provider.api_key = resolve_env_var(&provider.api_key);
343    }
344
345    // Resolve env vars in image provider configs
346    for provider in config.image_providers.values_mut() {
347        provider.api_key = resolve_env_var(&provider.api_key);
348    }
349
350    // Also resolve env vars in channel configs
351    if let Some(ref mut channels) = config.channels {
352        if let Some(ref mut qq_bot) = channels.qq_bot {
353            qq_bot.app_id = resolve_env_var(&qq_bot.app_id);
354            qq_bot.app_secret = resolve_env_var(&qq_bot.app_secret);
355        }
356    }
357
358    Ok(config)
359}
360
361/// Load .env files in order: workdir first (higher priority), then global (lower priority).
362/// Workdir vars will override global vars.
363pub fn load_env_from(workdir: Option<&std::path::Path>) {
364    // Collect all env paths, workdir first (higher priority)
365    let mut env_paths = Vec::new();
366
367    // Workdir-specific .env (highest priority)
368    if let Some(workdir) = workdir {
369        let local_env = workdir.join(".robit").join(".env");
370        if local_env.exists() {
371            env_paths.push(local_env);
372        }
373    } else if let Ok(cwd) = std::env::current_dir() {
374        let local_env = cwd.join(".robit").join(".env");
375        if local_env.exists() {
376            env_paths.push(local_env);
377        }
378    }
379
380    // Global .env (lowest priority)
381    if let Ok(robit_dir) = robit_home() {
382        let env_path = robit_dir.join(".env");
383        if env_path.exists() {
384            env_paths.push(env_path);
385        }
386    }
387
388    // Load in reverse order (global first, then workdir) so workdir overrides global
389    // Use dotenvy::from_path_iter to load and manually set vars to enable overriding
390    for path in env_paths.iter().rev() {
391        if let Ok(iter) = dotenvy::from_path_iter(path) {
392            for item in iter {
393                if let Ok((key, value)) = item {
394                    std::env::set_var(key, value);
395                }
396            }
397        }
398    }
399}
400
401/// Load .env from ~/.robit/.env if it exists (deprecated, use load_env_from).
402pub fn load_env() {
403    if let Ok(robit_dir) = robit_home() {
404        let env_path = robit_dir.join(".env");
405        if env_path.exists() {
406            let _ = dotenvy::from_path(&env_path);
407        }
408    }
409}
410
411/// Find the config file path following the search order.
412fn find_config_path(workdir: Option<&std::path::Path>) -> Result<PathBuf, LlmError> {
413    // 1. Project-local: workdir/.robit/config.toml (if workdir provided)
414    if let Some(workdir) = workdir {
415        let local_path = workdir.join(".robit").join("config.toml");
416        if local_path.exists() {
417            return Ok(local_path);
418        }
419    }
420
421    // 2. Project-local: cwd/.robit/config.toml (if workdir not provided or no config there)
422    if let Ok(cwd) = std::env::current_dir() {
423        let local_path = cwd.join(".robit").join("config.toml");
424        if local_path.exists() {
425            return Ok(local_path);
426        }
427    }
428
429    // 3. Global: ~/.robit/config.toml
430    let global_path = robit_home()?.join("config.toml");
431    if global_path.exists() {
432        return Ok(global_path);
433    }
434
435    Err(LlmError::ConfigError(format!(
436        "Configuration file config.toml not found.\n\
437         Please create one of the following:\n\
438         - Project-local: .robit/config.toml\n\
439         - Global: {}",
440        global_path.display()
441    )))
442}
443
444/// Resolve which model to use.
445///
446/// `default_model` uses "provider/model" format.
447/// Priority: explicit `provider_name` argument > `default_model` field > first available.
448///
449/// When `provider_name` is None, parses `default_model` (e.g. "deepseek/deepseek-chat")
450/// into provider key and model ID.
451pub fn resolve_profile(
452    config: &RobitConfig,
453    provider_name: Option<&str>,
454) -> Result<ResolvedModel, LlmError> {
455    let (provider_key, model_id) = if let Some(name) = provider_name {
456        // Explicit provider override — use its first model
457        let provider = config.providers.get(name).ok_or_else(|| {
458            LlmError::ConfigError(format!(
459                "Provider '{}' is not defined in config.toml. Available providers: {:?}",
460                name,
461                config.providers.keys().collect::<Vec<_>>()
462            ))
463        })?;
464        let first_model = provider.models.first().ok_or_else(|| {
465            LlmError::ConfigError(format!("Provider '{}' has no models defined", name))
466        })?;
467        (name.to_string(), first_model.id.clone())
468    } else if let Some(ref default_model) = config.default_model {
469        parse_default_model(default_model)?
470    } else {
471        // Fall back to first available provider + first model
472        let (key, provider) = config.providers.iter().next().ok_or_else(|| {
473            LlmError::ConfigError("No providers defined in config.toml".to_string())
474        })?;
475        let first_model = provider.models.first().ok_or_else(|| {
476            LlmError::ConfigError(format!("Provider '{}' has no models defined", key))
477        })?;
478        (key.clone(), first_model.id.clone())
479    };
480
481    let provider = config.providers.get(&provider_key).ok_or_else(|| {
482        LlmError::ConfigError(format!(
483            "Provider '{}' is not defined in config.toml. Available providers: {:?}",
484            provider_key,
485            config.providers.keys().collect::<Vec<_>>()
486        ))
487    })?;
488
489    // Find the matching model
490    let model = provider
491        .models
492        .iter()
493        .find(|m| m.id == model_id)
494        .ok_or_else(|| {
495            let available: Vec<&str> = provider.models.iter().map(|m| m.id.as_str()).collect();
496            LlmError::ConfigError(format!(
497                "Model '{}' not found in provider '{}'. Available models: {:?}",
498                model_id, provider_key, available
499            ))
500        })?;
501
502    // Validate API key
503    if provider.api_key.is_empty() || provider.api_key.starts_with("${") {
504        return Err(LlmError::ConfigError(format!(
505            "Provider '{}' API key is not configured or the environment variable is not set",
506            provider_key
507        )));
508    }
509
510    Ok(ResolvedModel {
511        profile_name: provider_key,
512        model_id: model.id.clone(),
513        base_url: provider.base_url.clone(),
514        api_key: provider.api_key.clone(),
515        max_tokens: model.max_tokens,
516        temperature: model.temperature,
517        context_window: model.context_window,
518        supports_images: model.supports_images.unwrap_or(false),
519        supports_tools: model.supports_tools.unwrap_or(false),
520    })
521}
522
523/// Parse "provider/model" format from default_model.
524///
525/// Returns (provider_key, model_id).
526fn parse_default_model(default_model: &str) -> Result<(String, String), LlmError> {
527    let parts: Vec<&str> = default_model.splitn(2, '/').collect();
528    if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() {
529        return Err(LlmError::ConfigError(format!(
530            "Invalid default_model '{}' format, expected 'provider/model' (e.g. 'deepseek/deepseek-chat')",
531            default_model
532        )));
533    }
534    Ok((parts[0].to_string(), parts[1].to_string()))
535}
536
537// ============================================================================
538// Image provider resolution
539// ============================================================================
540
541/// A fully resolved image generation provider, ready for client construction.
542///
543/// Resolved from `default_image_model` (in "provider/model" format) together
544/// with the matching `ImageProviderConfig`.
545#[derive(Debug, Clone)]
546pub struct ResolvedImageProvider {
547    /// Provider key in config (e.g. "wanxiang").
548    pub provider_name: String,
549    /// Model ID parsed from `default_image_model` (e.g. "wan2.7-image-pro").
550    pub model_id: String,
551    /// API base URL.
552    pub base_url: String,
553    /// API key (env vars already resolved).
554    pub api_key: String,
555    /// Protocol used by this provider.
556    pub protocol: ImageProtocol,
557    /// Call mode (only effective for DashScope).
558    pub mode: ImageCallMode,
559    /// Polling interval in seconds for async mode.
560    pub poll_interval_secs: u64,
561    /// Total polling timeout in seconds for async mode.
562    pub poll_timeout_secs: u64,
563}
564
565/// Resolve the image generation provider to use.
566///
567/// Requires `default_image_model` ("provider/model" format) to be configured.
568/// Without it, image generation is considered disabled and the tool is not
569/// registered.
570///
571/// Returns an error if no image providers are configured, `default_image_model`
572/// is absent, the referenced provider/model is not found, or the API key is
573/// empty.
574pub fn resolve_image_provider(config: &RobitConfig) -> Result<ResolvedImageProvider, LlmError> {
575    if config.image_providers.is_empty() {
576        return Err(LlmError::ConfigError(
577            "No image providers defined in config.toml".to_string(),
578        ));
579    }
580
581    // default_image_model is required - without it we don't know which
582    // provider/model to use, so image generation is considered disabled.
583    let default = config.default_image_model.as_ref().ok_or_else(|| {
584        LlmError::ConfigError(
585            "default_image_model is not configured. Set it to \"provider/model\" \
586             (e.g. \"wanxiang/wan2.7-image-pro\") to enable image generation."
587                .to_string(),
588        )
589    })?;
590
591    let (provider_key, model_id) = parse_default_model(default)?;
592
593    let provider = config.image_providers.get(&provider_key).ok_or_else(|| {
594        let available: Vec<&str> = config.image_providers.keys().map(|s| s.as_str()).collect();
595        LlmError::ConfigError(format!(
596            "Image provider '{}' is not defined in config.toml. Available image providers: {:?}",
597            provider_key, available
598        ))
599    })?;
600
601    // Validate that the model exists in this provider
602    let model_exists = provider.models.iter().any(|m| m.id == model_id);
603    if !model_exists {
604        let available: Vec<&str> = provider.models.iter().map(|m| m.id.as_str()).collect();
605        return Err(LlmError::ConfigError(format!(
606            "Image model '{}' not found in provider '{}'. Available models: {:?}",
607            model_id, provider_key, available
608        )));
609    }
610
611    // Validate API key
612    if provider.api_key.is_empty() || provider.api_key.starts_with("${") {
613        return Err(LlmError::ConfigError(format!(
614            "Image provider '{}' API key is not configured or the environment variable is not set",
615            provider_key
616        )));
617    }
618
619    Ok(ResolvedImageProvider {
620        provider_name: provider_key,
621        model_id,
622        base_url: provider.base_url.clone(),
623        api_key: provider.api_key.clone(),
624        protocol: provider.protocol.clone(),
625        mode: provider.mode.clone(),
626        poll_interval_secs: provider.poll_interval_secs,
627        poll_timeout_secs: provider.poll_timeout_secs,
628    })
629}
630
631// ============================================================================
632// Tests
633// ============================================================================
634
635#[cfg(test)]
636mod tests {
637    use super::*;
638
639    #[test]
640    fn test_resolve_env_var_with_env_set() {
641        std::env::set_var("ROBIT_TEST_KEY", "test-value-123");
642        assert_eq!(resolve_env_var("${ROBIT_TEST_KEY}"), "test-value-123");
643        std::env::remove_var("ROBIT_TEST_KEY");
644    }
645
646    #[test]
647    fn test_resolve_env_var_without_env() {
648        assert_eq!(
649            resolve_env_var("${ROBIT_NONEXISTENT_KEY}"),
650            "${ROBIT_NONEXISTENT_KEY}"
651        );
652    }
653
654    #[test]
655    fn test_resolve_env_var_plain_string() {
656        assert_eq!(resolve_env_var("plain-key"), "plain-key");
657    }
658
659    #[test]
660    fn test_parse_robit_config() {
661        let toml_str = r#"
662            default_model = "deepseek/deepseek-chat"
663
664            [providers.deepseek]
665            name = "DeepSeek"
666            base_url = "https://api.deepseek.com"
667            api_key = "sk-test-key"
668
669            [[providers.deepseek.models]]
670            id = "deepseek-chat"
671            name = "DeepSeek Chat"
672            context_window = 65536
673            max_output_tokens = 8192
674            temperature = 0.0
675            max_tokens = 4096
676
677            [[providers.deepseek.models]]
678            id = "deepseek-reasoner"
679            name = "DeepSeek Reasoner"
680            context_window = 65536
681            temperature = 0.6
682
683            [providers.qwen]
684            name = "通义千问"
685            base_url = "https://dashscope.aliyuncs.com/compatible-mode/v1"
686            api_key = "sk-qwen-key"
687
688            [[providers.qwen.models]]
689            id = "qwen-max"
690            name = "Qwen Max"
691            context_window = 32768
692
693            [app]
694            log_level = "DEBUG"
695            max_steps = 10
696            global_storage = true
697
698            [app.context]
699            max_output_lines = 500
700            reserve_ratio = 0.2
701
702            [app.retry]
703            max_retries = 3
704        "#;
705
706        let config: RobitConfig = toml::from_str(toml_str).unwrap();
707
708        // Default model
709        assert_eq!(
710            config.default_model.as_deref(),
711            Some("deepseek/deepseek-chat")
712        );
713
714        // Providers
715        assert_eq!(config.providers.len(), 2);
716
717        // DeepSeek provider
718        let ds = &config.providers["deepseek"];
719        assert_eq!(ds.name.as_deref(), Some("DeepSeek"));
720        assert_eq!(ds.base_url, "https://api.deepseek.com");
721        assert_eq!(ds.api_key, "sk-test-key");
722        assert_eq!(ds.models.len(), 2);
723        assert_eq!(ds.models[0].id, "deepseek-chat");
724        assert_eq!(ds.models[0].context_window, Some(65536));
725        assert_eq!(ds.models[0].temperature, Some(0.0));
726        assert_eq!(ds.models[0].max_tokens, Some(4096));
727        assert_eq!(ds.models[1].id, "deepseek-reasoner");
728        assert_eq!(ds.models[1].temperature, Some(0.6));
729
730        // Qwen provider
731        let qw = &config.providers["qwen"];
732        assert_eq!(qw.name.as_deref(), Some("通义千问"));
733        assert_eq!(qw.models.len(), 1);
734        assert_eq!(qw.models[0].id, "qwen-max");
735
736        // App section
737        let app = config.app.as_ref().unwrap();
738        assert_eq!(app.log_level.as_deref(), Some("DEBUG"));
739        assert_eq!(app.max_steps, Some(10));
740        assert_eq!(app.global_storage, Some(true));
741        assert!(app.context.is_some());
742        assert_eq!(app.context.as_ref().unwrap().max_output_lines, Some(500));
743        assert!(app.retry.is_some());
744        assert_eq!(app.retry.as_ref().unwrap().max_retries, Some(3));
745    }
746
747    #[test]
748    fn test_parse_config_minimal() {
749        let toml_str = r#"
750            [providers.default]
751            base_url = "https://api.deepseek.com"
752            api_key = "sk-test"
753
754            [[providers.default.models]]
755            id = "deepseek-chat"
756        "#;
757
758        let config: RobitConfig = toml::from_str(toml_str).unwrap();
759        assert!(config.default_model.is_none());
760        assert!(config.app.is_none());
761        assert_eq!(config.providers.len(), 1);
762    }
763
764    #[test]
765    fn test_resolve_profile_from_default_model() {
766        let config = make_test_config();
767        let resolved = resolve_profile(&config, None).unwrap();
768        assert_eq!(resolved.profile_name, "deepseek");
769        assert_eq!(resolved.model_id, "deepseek-chat");
770        assert_eq!(resolved.base_url, "https://api.deepseek.com");
771        assert_eq!(resolved.api_key, "sk-test");
772        assert_eq!(resolved.context_window, Some(65536));
773        assert_eq!(resolved.temperature, Some(0.0));
774        assert_eq!(resolved.max_tokens, Some(4096));
775    }
776
777    #[test]
778    fn test_resolve_profile_explicit_provider() {
779        let config = make_test_config();
780        // Explicit provider — uses first model of that provider
781        let resolved = resolve_profile(&config, Some("qwen")).unwrap();
782        assert_eq!(resolved.profile_name, "qwen");
783        assert_eq!(resolved.model_id, "qwen-max");
784        assert_eq!(
785            resolved.base_url,
786            "https://dashscope.aliyuncs.com/compatible-mode/v1"
787        );
788    }
789
790    #[test]
791    fn test_resolve_profile_first_available() {
792        // No default_model and no explicit provider — use first available
793        let toml_str = r#"
794            [providers.deepseek]
795            base_url = "https://api.deepseek.com"
796            api_key = "sk-test"
797
798            [[providers.deepseek.models]]
799            id = "deepseek-chat"
800        "#;
801        let config: RobitConfig = toml::from_str(toml_str).unwrap();
802        let resolved = resolve_profile(&config, None).unwrap();
803        assert_eq!(resolved.profile_name, "deepseek");
804        assert_eq!(resolved.model_id, "deepseek-chat");
805    }
806
807    #[test]
808    fn test_resolve_profile_not_found() {
809        let config = make_test_config();
810        let result = resolve_profile(&config, Some("nonexistent"));
811        assert!(result.is_err());
812    }
813
814    #[test]
815    fn test_resolve_profile_model_not_found() {
816        let toml_str = r#"
817            default_model = "deepseek/nonexistent-model"
818
819            [providers.deepseek]
820            base_url = "https://api.deepseek.com"
821            api_key = "sk-test"
822
823            [[providers.deepseek.models]]
824            id = "deepseek-chat"
825        "#;
826        let config: RobitConfig = toml::from_str(toml_str).unwrap();
827        let result = resolve_profile(&config, None);
828        assert!(result.is_err());
829    }
830
831    #[test]
832    fn test_resolve_profile_invalid_default_model_format() {
833        let toml_str = r#"
834            default_model = "invalid-no-slash"
835
836            [providers.deepseek]
837            base_url = "https://api.deepseek.com"
838            api_key = "sk-test"
839
840            [[providers.deepseek.models]]
841            id = "deepseek-chat"
842        "#;
843        let config: RobitConfig = toml::from_str(toml_str).unwrap();
844        let result = resolve_profile(&config, None);
845        assert!(result.is_err());
846        assert!(result
847            .unwrap_err()
848            .to_string()
849            .contains("Invalid default_model"));
850    }
851
852    #[test]
853    fn test_resolve_profile_empty_api_key() {
854        let toml_str = r#"
855            [providers.deepseek]
856            base_url = "https://api.deepseek.com"
857            api_key = ""
858
859            [[providers.deepseek.models]]
860            id = "deepseek-chat"
861        "#;
862        let config: RobitConfig = toml::from_str(toml_str).unwrap();
863        let result = resolve_profile(&config, None);
864        assert!(result.is_err());
865    }
866
867    #[test]
868    fn test_parse_enabled_skills() {
869        let toml_str = r#"
870            default_model = "deepseek/deepseek-chat"
871
872            [providers.deepseek]
873            base_url = "https://api.deepseek.com"
874            api_key = "sk-test"
875
876            [[providers.deepseek.models]]
877            id = "deepseek-chat"
878
879            [app]
880            enabled_skills = ["code-review", "refactor"]
881        "#;
882
883        let config: RobitConfig = toml::from_str(toml_str).unwrap();
884        let app = config.app.as_ref().unwrap();
885        assert!(app.enabled_skills.is_some());
886        let skills = app.enabled_skills.as_ref().unwrap();
887        assert_eq!(skills.len(), 2);
888        assert_eq!(skills[0], "code-review");
889        assert_eq!(skills[1], "refactor");
890    }
891
892    #[test]
893    fn test_parse_enabled_tools() {
894        let toml_str = r#"
895            default_model = "deepseek/deepseek-chat"
896
897            [providers.deepseek]
898            base_url = "https://api.deepseek.com"
899            api_key = "sk-test"
900
901            [[providers.deepseek.models]]
902            id = "deepseek-chat"
903
904            [app]
905            enabled_tools = ["read", "bash", "edit", "write", "grep", "find", "ls"]
906        "#;
907
908        let config: RobitConfig = toml::from_str(toml_str).unwrap();
909        let app = config.app.as_ref().unwrap();
910        assert!(app.enabled_tools.is_some());
911        let tools = app.enabled_tools.as_ref().unwrap();
912        assert_eq!(tools.len(), 7);
913        assert_eq!(tools[0], "read");
914        assert_eq!(tools[1], "bash");
915        assert_eq!(tools[2], "edit");
916        assert_eq!(tools[3], "write");
917        assert_eq!(tools[4], "grep");
918        assert_eq!(tools[5], "find");
919        assert_eq!(tools[6], "ls");
920    }
921
922    #[test]
923    fn test_parse_auto_approve() {
924        let toml_str = r#"
925            default_model = "deepseek/deepseek-chat"
926
927            [providers.deepseek]
928            base_url = "https://api.deepseek.com"
929            api_key = "sk-test"
930
931            [[providers.deepseek.models]]
932            id = "deepseek-chat"
933
934            [app]
935            auto_approve = true
936        "#;
937
938        let config: RobitConfig = toml::from_str(toml_str).unwrap();
939        let app = config.app.as_ref().unwrap();
940        assert_eq!(app.auto_approve, Some(true));
941    }
942
943    #[test]
944    fn test_parse_auto_approve_default_none() {
945        let toml_str = r#"
946            default_model = "deepseek/deepseek-chat"
947
948            [providers.deepseek]
949            base_url = "https://api.deepseek.com"
950            api_key = "sk-test"
951
952            [[providers.deepseek.models]]
953            id = "deepseek-chat"
954
955            [app]
956        "#;
957
958        let config: RobitConfig = toml::from_str(toml_str).unwrap();
959        let app = config.app.as_ref().unwrap();
960        assert_eq!(app.auto_approve, None);
961    }
962
963    fn make_test_config() -> RobitConfig {
964        let toml_str = r#"
965            default_model = "deepseek/deepseek-chat"
966
967            [providers.deepseek]
968            base_url = "https://api.deepseek.com"
969            api_key = "sk-test"
970
971            [[providers.deepseek.models]]
972            id = "deepseek-chat"
973            context_window = 65536
974            temperature = 0.0
975            max_tokens = 4096
976
977            [providers.qwen]
978            base_url = "https://dashscope.aliyuncs.com/compatible-mode/v1"
979            api_key = "sk-qwen-test"
980
981            [[providers.qwen.models]]
982            id = "qwen-max"
983            context_window = 32768
984        "#;
985
986        toml::from_str(toml_str).unwrap()
987    }
988
989    #[test]
990    fn test_parse_channels_and_bot_sections() {
991        let toml_str = r#"
992            default_model = "deepseek/deepseek-chat"
993
994            [providers.deepseek]
995            base_url = "https://api.deepseek.com"
996            api_key = "sk-test"
997
998            [[providers.deepseek.models]]
999            id = "deepseek-chat"
1000
1001            [channels.qq_bot]
1002            app_id = "123456789"
1003            app_secret = "secret-value"
1004
1005            [app.bot]
1006            confirm_timeout_secs = 60
1007            session_timeout_minutes = 30
1008
1009            [app.bot.confirm_keywords]
1010            approve = ["确认", "yes"]
1011            reject = ["取消", "no"]
1012        "#;
1013
1014        let config: RobitConfig = toml::from_str(toml_str).unwrap();
1015
1016        // channels.qq_bot
1017        let qq = config
1018            .channels
1019            .as_ref()
1020            .and_then(|c| c.qq_bot.as_ref())
1021            .expect("qq_bot config missing");
1022        assert_eq!(qq.app_id, "123456789");
1023        assert_eq!(qq.app_secret, "secret-value");
1024
1025        // app.bot
1026        let bot = config.app.as_ref().unwrap().bot.as_ref().unwrap();
1027        assert_eq!(bot.confirm_timeout_secs, Some(60));
1028        assert_eq!(bot.session_timeout_minutes, Some(30));
1029        let kw = bot.confirm_keywords.as_ref().unwrap();
1030        assert_eq!(kw.approve.as_ref().unwrap(), &vec!["确认".to_string(), "yes".to_string()]);
1031        assert_eq!(kw.reject.as_ref().unwrap(), &vec!["取消".to_string(), "no".to_string()]);
1032    }
1033
1034    #[test]
1035    fn test_config_without_channels_still_parses() {
1036        let toml_str = r#"
1037            [providers.deepseek]
1038            base_url = "https://api.deepseek.com"
1039            api_key = "sk-test"
1040
1041            [[providers.deepseek.models]]
1042            id = "deepseek-chat"
1043        "#;
1044
1045        let config: RobitConfig = toml::from_str(toml_str).unwrap();
1046        assert!(config.channels.is_none());
1047        assert!(config.app.is_none() || config.app.as_ref().unwrap().bot.is_none());
1048    }
1049
1050    // ------------------------------------------------------------------
1051    // Image provider config tests
1052    // ------------------------------------------------------------------
1053
1054    fn make_image_test_config() -> RobitConfig {
1055        let toml_str = r#"
1056            default_image_model = "wanxiang/wan2.7-image-pro"
1057
1058            [providers.test]
1059            base_url = "https://api.test.com"
1060            api_key = "sk-test"
1061
1062            [[providers.test.models]]
1063            id = "test-model"
1064
1065            [image_providers.wanxiang]
1066            name = "通义万相"
1067            base_url = "https://ws.cn-beijing.maas.aliyuncs.com"
1068            api_key = "sk-test"
1069            protocol = "dashscope"
1070            mode = "async"
1071
1072            [[image_providers.wanxiang.models]]
1073            id = "wan2.7-image-pro"
1074            name = "万相2.7 Pro"
1075
1076            [[image_providers.wanxiang.models]]
1077            id = "wan2.7-image"
1078
1079            [image_providers.dalle]
1080            base_url = "https://api.openai.com/v1"
1081            api_key = "sk-openai"
1082
1083            [[image_providers.dalle.models]]
1084            id = "dall-e-3"
1085        "#;
1086        toml::from_str(toml_str).unwrap()
1087    }
1088
1089    #[test]
1090    fn test_parse_image_providers() {
1091        let config = make_image_test_config();
1092
1093        assert_eq!(
1094            config.default_image_model.as_deref(),
1095            Some("wanxiang/wan2.7-image-pro")
1096        );
1097        assert_eq!(config.image_providers.len(), 2);
1098
1099        let wx = &config.image_providers["wanxiang"];
1100        assert_eq!(wx.name.as_deref(), Some("通义万相"));
1101        assert_eq!(wx.base_url, "https://ws.cn-beijing.maas.aliyuncs.com");
1102        assert_eq!(wx.api_key, "sk-test");
1103        assert_eq!(wx.protocol, ImageProtocol::Dashscope);
1104        assert_eq!(wx.mode, ImageCallMode::Async);
1105        assert_eq!(wx.poll_interval_secs, 3);
1106        assert_eq!(wx.poll_timeout_secs, 300);
1107        assert_eq!(wx.models.len(), 2);
1108        assert_eq!(wx.models[0].id, "wan2.7-image-pro");
1109
1110        // Defaults: openai protocol + sync mode
1111        let dalle = &config.image_providers["dalle"];
1112        assert_eq!(dalle.protocol, ImageProtocol::Openai);
1113        assert_eq!(dalle.mode, ImageCallMode::Sync);
1114    }
1115
1116    #[test]
1117    fn test_resolve_image_provider_from_default() {
1118        let config = make_image_test_config();
1119        let resolved = resolve_image_provider(&config).unwrap();
1120        assert_eq!(resolved.provider_name, "wanxiang");
1121        assert_eq!(resolved.model_id, "wan2.7-image-pro");
1122        assert_eq!(resolved.base_url, "https://ws.cn-beijing.maas.aliyuncs.com");
1123        assert_eq!(resolved.protocol, ImageProtocol::Dashscope);
1124        assert_eq!(resolved.mode, ImageCallMode::Async);
1125    }
1126
1127    #[test]
1128    fn test_resolve_image_provider_no_default_model() {
1129        // image_providers configured but default_image_model absent -> error
1130        // (image generation is considered disabled in this case)
1131        let toml_str = r#"
1132            [providers.test]
1133            base_url = "https://api.test.com"
1134            api_key = "sk-test"
1135
1136            [[providers.test.models]]
1137            id = "test-model"
1138
1139            [image_providers.wanxiang]
1140            base_url = "https://ws.cn-beijing.maas.aliyuncs.com"
1141            api_key = "sk-test"
1142
1143            [[image_providers.wanxiang.models]]
1144            id = "wan2.7-image-pro"
1145        "#;
1146        let config: RobitConfig = toml::from_str(toml_str).unwrap();
1147        assert!(resolve_image_provider(&config).is_err());
1148    }
1149
1150    #[test]
1151    fn test_resolve_image_provider_none_configured() {
1152        let toml_str = r#"
1153            [providers.deepseek]
1154            base_url = "https://api.deepseek.com"
1155            api_key = "sk-test"
1156
1157            [[providers.deepseek.models]]
1158            id = "deepseek-chat"
1159        "#;
1160        let config: RobitConfig = toml::from_str(toml_str).unwrap();
1161        assert!(resolve_image_provider(&config).is_err());
1162    }
1163
1164    #[test]
1165    fn test_resolve_image_provider_empty_api_key() {
1166        let toml_str = r#"
1167            default_image_model = "wanxiang/wan2.7-image-pro"
1168
1169            [providers.test]
1170            base_url = "https://api.test.com"
1171            api_key = "sk-test"
1172
1173            [[providers.test.models]]
1174            id = "test-model"
1175
1176            [image_providers.wanxiang]
1177            base_url = "https://ws.cn-beijing.maas.aliyuncs.com"
1178            api_key = ""
1179
1180            [[image_providers.wanxiang.models]]
1181            id = "wan2.7-image-pro"
1182        "#;
1183        let config: RobitConfig = toml::from_str(toml_str).unwrap();
1184        assert!(resolve_image_provider(&config).is_err());
1185    }
1186
1187    #[test]
1188    fn test_resolve_image_provider_model_not_found() {
1189        let toml_str = r#"
1190            default_image_model = "wanxiang/nonexistent-model"
1191
1192            [providers.test]
1193            base_url = "https://api.test.com"
1194            api_key = "sk-test"
1195
1196            [[providers.test.models]]
1197            id = "test-model"
1198
1199            [image_providers.wanxiang]
1200            base_url = "https://ws.cn-beijing.maas.aliyuncs.com"
1201            api_key = "sk-test"
1202
1203            [[image_providers.wanxiang.models]]
1204            id = "wan2.7-image-pro"
1205        "#;
1206        let config: RobitConfig = toml::from_str(toml_str).unwrap();
1207        assert!(resolve_image_provider(&config).is_err());
1208    }
1209
1210    #[test]
1211    fn test_resolve_image_provider_env_var_substitution() {
1212        std::env::set_var("ROBIT_IMG_TEST_KEY", "sk-from-env");
1213        let toml_str = r#"
1214            default_image_model = "wanxiang/wan2.7-image-pro"
1215
1216            [providers.test]
1217            base_url = "https://api.test.com"
1218            api_key = "sk-test"
1219
1220            [[providers.test.models]]
1221            id = "test-model"
1222
1223            [image_providers.wanxiang]
1224            base_url = "https://ws.cn-beijing.maas.aliyuncs.com"
1225            api_key = "${ROBIT_IMG_TEST_KEY}"
1226
1227            [[image_providers.wanxiang.models]]
1228            id = "wan2.7-image-pro"
1229        "#;
1230        // load_config resolves env vars; here we test resolve_image_provider
1231        // after manual substitution (load_config path is covered elsewhere).
1232        let mut config: RobitConfig = toml::from_str(toml_str).unwrap();
1233        for provider in config.image_providers.values_mut() {
1234            provider.api_key = resolve_env_var(&provider.api_key);
1235        }
1236        let resolved = resolve_image_provider(&config).unwrap();
1237        assert_eq!(resolved.api_key, "sk-from-env");
1238        std::env::remove_var("ROBIT_IMG_TEST_KEY");
1239    }
1240}