Skip to main content

vtcode_core/core/agent/
config.rs

1use std::path::{Path, PathBuf};
2use std::str::FromStr;
3
4use crate::cli::args::Cli;
5pub use crate::config::api_keys::api_key_env_var;
6use crate::config::api_keys::resolve_api_key_env;
7use crate::config::constants::defaults;
8use crate::config::loader::VTCodeConfig;
9use crate::config::models::Provider;
10use crate::config::types::{AgentConfig, ModelSelectionSource};
11use crate::llm::factory::infer_provider;
12use crate::utils::path::canonicalize_workspace;
13
14/// Resolved model selection for the current runtime, combining CLI overrides,
15/// workspace config defaults, and inferred provider information.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct RuntimeModelSelection {
18    /// The selected model identifier.
19    pub model: String,
20    /// The resolved provider name.
21    pub provider: String,
22    /// The credential identity selected for this provider.
23    pub api_key_env: String,
24    /// Where the model selection originated (CLI, workspace config, etc.).
25    pub model_source: ModelSelectionSource,
26}
27
28/// Resolve the model, provider, and selection source by merging CLI arguments
29/// with workspace configuration. CLI flags take precedence over config defaults.
30pub fn resolve_runtime_model_selection(args: &Cli, config: &VTCodeConfig) -> RuntimeModelSelection {
31    let (model, model_source) = if let Some(agent) = args.agent.clone() {
32        (agent, ModelSelectionSource::CliOverride)
33    } else if let Some(model) = args.model.clone() {
34        (model, ModelSelectionSource::CliOverride)
35    } else {
36        (config.agent.default_model.clone(), ModelSelectionSource::WorkspaceConfig)
37    };
38
39    let provider = resolve_provider(
40        args.provider.clone().or_else(provider_env_override),
41        config.agent.provider.as_str(),
42        &model,
43        model_source,
44    );
45
46    let api_key_env = if !args.api_key_env.trim().is_empty()
47        && !args.api_key_env.eq_ignore_ascii_case(defaults::DEFAULT_API_KEY_ENV)
48    {
49        resolve_api_key_env(&provider, &args.api_key_env)
50    } else if let Some(configured_env) = config.configured_api_key_env(&provider) {
51        resolve_api_key_env(&provider, &configured_env)
52    } else if config.agent.provider.eq_ignore_ascii_case(&provider) {
53        resolve_api_key_env(&provider, &config.agent.api_key_env)
54    } else {
55        api_key_env_var(&provider)
56    };
57
58    RuntimeModelSelection { model, provider, api_key_env, model_source }
59}
60
61/// Build a fully resolved [`AgentConfig`] from CLI arguments, workspace config,
62/// resolved model selection, API key, and theme selection.
63pub fn build_runtime_agent_config(
64    args: &Cli,
65    config: &VTCodeConfig,
66    workspace: PathBuf,
67    selection: RuntimeModelSelection,
68    api_key: String,
69    theme_selection: String,
70) -> AgentConfig {
71    let workspace = canonicalize_workspace(&workspace);
72    let cli_api_key_env = args.api_key_env.trim();
73    let api_key_env_override =
74        if cli_api_key_env.is_empty() || cli_api_key_env.eq_ignore_ascii_case(defaults::DEFAULT_API_KEY_ENV) {
75            None
76        } else {
77            Some(cli_api_key_env.to_owned())
78        };
79
80    let checkpointing_storage_dir =
81        resolve_checkpointing_storage_dir(&workspace, config.agent.checkpointing.storage_dir.as_deref());
82    let RuntimeModelSelection {
83        model,
84        provider,
85        api_key_env: selection_api_key_env,
86        model_source,
87    } = selection;
88    let api_key_env = api_key_env_override.unwrap_or(selection_api_key_env);
89
90    AgentConfig {
91        model,
92        api_key,
93        provider,
94        api_key_env,
95        workspace,
96        verbose: args.verbose,
97        quiet: args.quiet,
98        theme: theme_selection,
99        reasoning_effort: config.agent.reasoning_effort,
100        ui_surface: config.agent.ui_surface,
101        prompt_cache: config.prompt_cache.clone(),
102        model_source,
103        custom_api_keys: config.agent.custom_api_keys.clone(),
104        checkpointing_enabled: config.agent.checkpointing.enabled,
105        checkpointing_storage_dir,
106        checkpointing_max_snapshots: config.agent.checkpointing.max_snapshots,
107        checkpointing_max_age_days: config.agent.checkpointing.max_age_days,
108        max_conversation_turns: config.agent.max_conversation_turns,
109        model_behavior: Some(config.model.clone()),
110        openai_chatgpt_auth: None,
111    }
112}
113
114/// Resolve the checkpointing storage directory, joining relative paths against
115/// the workspace root. Returns `None` if no storage directory is configured.
116pub fn resolve_checkpointing_storage_dir(workspace: &Path, storage_dir: Option<&str>) -> Option<PathBuf> {
117    storage_dir.map(PathBuf::from).map(|candidate| {
118        if candidate.is_absolute() {
119            candidate
120        } else {
121            workspace.join(candidate)
122        }
123    })
124}
125
126/// Return a human-readable display name for the given provider, consulting
127/// custom provider configuration when available.
128pub fn provider_label(provider: &str, vt_cfg: Option<&VTCodeConfig>) -> String {
129    if let Some(vt_cfg) = vt_cfg {
130        return vt_cfg.provider_display_name(provider);
131    }
132
133    if provider.eq_ignore_ascii_case("codex") {
134        return "Codex".to_string();
135    }
136
137    Provider::from_str(provider)
138        .map(|resolved| resolved.label().to_string())
139        .unwrap_or_else(|_| provider.to_string())
140}
141
142fn resolve_provider(
143    cli_provider: Option<String>,
144    configured_provider: &str,
145    model: &str,
146    model_source: ModelSelectionSource,
147) -> String {
148    if let Some(provider) = cli_provider {
149        return provider;
150    }
151
152    if matches!(model_source, ModelSelectionSource::CliOverride)
153        && let Some(provider) = infer_provider(None, model)
154    {
155        return provider.to_string();
156    }
157
158    let configured_provider = configured_provider.trim();
159    if !configured_provider.is_empty() {
160        return configured_provider.to_owned();
161    }
162
163    infer_provider(None, model)
164        .map(|provider| provider.to_string())
165        .unwrap_or_else(|| defaults::DEFAULT_PROVIDER.to_owned())
166}
167
168fn provider_env_override() -> Option<String> {
169    std::env::var("VTCODE_PROVIDER")
170        .ok()
171        .or_else(|| std::env::var("provider").ok())
172        .map(|value| value.trim().to_owned())
173        .filter(|value| !value.is_empty())
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179    use clap::Parser;
180    use vtcode_commons::canonicalize;
181
182    #[test]
183    fn provider_resolution_prefers_configured_provider_for_config_model() {
184        let mut config = VTCodeConfig::default();
185        config.agent.provider = "zai".to_owned();
186        config.agent.default_model = crate::config::constants::models::ollama::MINIMAX_M3_CLOUD.to_owned();
187
188        let args = Cli::parse_from(["vtcode"]);
189        let selection = resolve_runtime_model_selection(&args, &config);
190
191        assert_eq!(selection.provider, "zai");
192        assert_eq!(selection.model_source, ModelSelectionSource::WorkspaceConfig);
193    }
194
195    #[test]
196    fn provider_resolution_infers_from_cli_model_without_cli_provider() {
197        let mut config = VTCodeConfig::default();
198        config.agent.provider = "zai".to_owned();
199
200        let args = Cli::parse_from([
201            "vtcode",
202            "--model",
203            crate::config::constants::models::ollama::MINIMAX_M3_CLOUD,
204        ]);
205        let selection = resolve_runtime_model_selection(&args, &config);
206
207        assert_eq!(selection.provider, "ollama-cloud");
208        assert_eq!(selection.model_source, ModelSelectionSource::CliOverride);
209    }
210
211    #[test]
212    fn provider_resolution_uses_cli_provider_when_present() {
213        let mut config = VTCodeConfig::default();
214        config.agent.provider = "zai".to_owned();
215
216        let args = Cli::parse_from([
217            "vtcode",
218            "--model",
219            crate::config::constants::models::ollama::MINIMAX_M3_CLOUD,
220            "--provider",
221            "minimax",
222        ]);
223        let selection = resolve_runtime_model_selection(&args, &config);
224
225        assert_eq!(selection.provider, "minimax");
226    }
227
228    #[test]
229    fn runtime_selection_uses_custom_provider_api_key_env() {
230        let mut config = VTCodeConfig::default();
231        config.agent.provider = "mycorp".to_owned();
232        config.agent.default_model = "corp-model".to_owned();
233        config.custom_providers.push(vtcode_config::core::CustomProviderConfig {
234            name: "mycorp".to_owned(),
235            display_name: "MyCorp".to_owned(),
236            base_url: "https://llm.example/v1".to_owned(),
237            context_window: None,
238            api_key_env: "CORP_SECRET".to_owned(),
239            auth: None,
240            model: "corp-model".to_owned(),
241            models: Vec::new(),
242            ..vtcode_config::core::CustomProviderConfig::default()
243        });
244
245        let selection = resolve_runtime_model_selection(&Cli::parse_from(["vtcode"]), &config);
246
247        assert_eq!(selection.provider, "mycorp");
248        assert_eq!(selection.api_key_env, "CORP_SECRET");
249    }
250
251    #[test]
252    fn runtime_selection_uses_builtin_provider_override_api_key_env() {
253        let mut config = VTCodeConfig::default();
254        config.agent.provider = "openai".to_owned();
255        config.provider_overrides.insert(
256            "openai".to_owned(),
257            vtcode_config::core::ProviderOverrideConfig {
258                models: Vec::new(),
259                base_url: None,
260                api_key_env: Some("CORP_OPENAI_SECRET".to_owned()),
261            },
262        );
263
264        let selection = resolve_runtime_model_selection(&Cli::parse_from(["vtcode"]), &config);
265
266        assert_eq!(selection.api_key_env, "CORP_OPENAI_SECRET");
267    }
268
269    #[test]
270    fn runtime_selection_reuses_persisted_key_for_cli_model_of_same_provider() {
271        let mut config = VTCodeConfig::default();
272        config.agent.provider = "mimo".to_owned();
273        config.agent.api_key_env = "MIMO_TOKEN_PLAN_KEY".to_owned();
274
275        let args = Cli::parse_from(["vtcode", "--model", "mimo-v2-flash"]);
276        let selection = resolve_runtime_model_selection(&args, &config);
277
278        assert_eq!(selection.provider, "mimo");
279        assert_eq!(selection.api_key_env, "MIMO_TOKEN_PLAN_KEY");
280    }
281
282    #[test]
283    fn build_runtime_agent_config_uses_provider_default_api_key_env() {
284        let mut config = VTCodeConfig::default();
285        config.agent.api_key_env = defaults::DEFAULT_API_KEY_ENV.to_owned();
286
287        let args = Cli::parse_from(["vtcode", "--provider", "openai"]);
288        let selection = RuntimeModelSelection {
289            model: crate::config::constants::models::openai::GPT_5.to_owned(),
290            provider: "openai".to_owned(),
291            api_key_env: "OPENAI_API_KEY".to_owned(),
292            model_source: ModelSelectionSource::CliOverride,
293        };
294
295        let agent_config = build_runtime_agent_config(
296            &args,
297            &config,
298            PathBuf::from("/workspace"),
299            selection,
300            "test-key".to_owned(),
301            "dark".to_owned(),
302        );
303
304        assert_eq!(agent_config.api_key_env, "OPENAI_API_KEY");
305    }
306
307    #[test]
308    fn build_runtime_agent_config_respects_cli_api_key_env_override() {
309        let config = VTCodeConfig::default();
310        let args = Cli::parse_from(["vtcode", "--provider", "openai", "--api-key-env", "CUSTOM_OPENAI_KEY"]);
311        let selection = RuntimeModelSelection {
312            model: crate::config::constants::models::openai::GPT_5.to_owned(),
313            provider: "openai".to_owned(),
314            api_key_env: "OPENAI_API_KEY".to_owned(),
315            model_source: ModelSelectionSource::CliOverride,
316        };
317
318        let agent_config = build_runtime_agent_config(
319            &args,
320            &config,
321            PathBuf::from("/workspace"),
322            selection,
323            "test-key".to_owned(),
324            "dark".to_owned(),
325        );
326
327        assert_eq!(agent_config.api_key_env, "CUSTOM_OPENAI_KEY");
328    }
329
330    #[test]
331    fn provider_label_uses_custom_provider_display_name() {
332        let mut config = VTCodeConfig::default();
333        config.custom_providers.push(vtcode_config::core::CustomProviderConfig {
334            name: "mycorp".to_string(),
335            display_name: "MyCorporateName".to_string(),
336            base_url: "https://llm.example/v1".to_string(),
337            context_window: None,
338            api_key_env: "MYCORP_API_KEY".to_string(),
339            auth: None,
340            model: "gpt-5-mini".to_string(),
341            models: Vec::new(),
342            ..vtcode_config::core::CustomProviderConfig::default()
343        });
344
345        assert_eq!(provider_label("mycorp", Some(&config)), "MyCorporateName");
346    }
347
348    #[test]
349    fn resolve_checkpointing_storage_dir_preserves_absolute_path() {
350        let resolved = resolve_checkpointing_storage_dir(Path::new("/workspace"), Some("/tmp/vtcode-checkpoints"));
351
352        assert_eq!(resolved, Some(PathBuf::from("/tmp/vtcode-checkpoints")));
353    }
354
355    #[test]
356    fn build_runtime_agent_config_canonicalizes_relative_workspace() {
357        let temp = tempfile::TempDir::new().expect("temp dir");
358        let original_dir = std::env::current_dir().expect("current dir");
359        std::env::set_current_dir(temp.path()).expect("set current dir");
360
361        let config = VTCodeConfig::default();
362        let args = Cli::parse_from(["vtcode"]);
363        let selection = RuntimeModelSelection {
364            model: crate::config::constants::models::openai::GPT_5.to_owned(),
365            provider: "openai".to_owned(),
366            api_key_env: "OPENAI_API_KEY".to_owned(),
367            model_source: ModelSelectionSource::CliOverride,
368        };
369
370        let agent_config = build_runtime_agent_config(
371            &args,
372            &config,
373            PathBuf::from("."),
374            selection,
375            "test-key".to_owned(),
376            "dark".to_owned(),
377        );
378
379        std::env::set_current_dir(original_dir).expect("restore current dir");
380        let expected_workspace = canonicalize(temp.path()).expect("canonical workspace");
381
382        assert_eq!(agent_config.workspace, expected_workspace);
383    }
384}