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