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