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#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct RuntimeModelSelection {
18 pub model: String,
20 pub provider: String,
22 pub api_key_env: String,
24 pub model_source: ModelSelectionSource,
26}
27
28pub 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
61pub 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
114pub 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
126pub 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}