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)]
15pub struct RuntimeModelSelection {
16 pub model: String,
17 pub provider: String,
18 pub model_source: ModelSelectionSource,
19}
20
21pub fn resolve_runtime_model_selection(args: &Cli, config: &VTCodeConfig) -> RuntimeModelSelection {
22 let (model, model_source) = if let Some(agent) = args.agent.clone() {
23 (agent, ModelSelectionSource::CliOverride)
24 } else if let Some(model) = args.model.clone() {
25 (model, ModelSelectionSource::CliOverride)
26 } else {
27 (
28 config.agent.default_model.clone(),
29 ModelSelectionSource::WorkspaceConfig,
30 )
31 };
32
33 let provider = resolve_provider(
34 args.provider.clone().or_else(provider_env_override),
35 config.agent.provider.as_str(),
36 &model,
37 model_source,
38 );
39
40 RuntimeModelSelection {
41 model,
42 provider,
43 model_source,
44 }
45}
46
47pub fn build_runtime_agent_config(
48 args: &Cli,
49 config: &VTCodeConfig,
50 workspace: PathBuf,
51 selection: RuntimeModelSelection,
52 api_key: String,
53 theme_selection: String,
54) -> AgentConfig {
55 let workspace = canonicalize_workspace(&workspace);
56 let cli_api_key_env = args.api_key_env.trim();
57 let api_key_env_override = if cli_api_key_env.is_empty()
58 || cli_api_key_env.eq_ignore_ascii_case(defaults::DEFAULT_API_KEY_ENV)
59 {
60 None
61 } else {
62 Some(cli_api_key_env.to_owned())
63 };
64
65 let checkpointing_storage_dir = resolve_checkpointing_storage_dir(
66 &workspace,
67 config.agent.checkpointing.storage_dir.as_deref(),
68 );
69 let RuntimeModelSelection {
70 model,
71 provider,
72 model_source,
73 } = selection;
74 let api_key_env = api_key_env_override
75 .unwrap_or_else(|| resolve_api_key_env(&provider, &config.agent.api_key_env));
76
77 AgentConfig {
78 model,
79 api_key,
80 provider,
81 api_key_env,
82 workspace,
83 verbose: args.verbose,
84 quiet: args.quiet,
85 theme: theme_selection,
86 reasoning_effort: config.agent.reasoning_effort,
87 ui_surface: config.agent.ui_surface,
88 prompt_cache: config.prompt_cache.clone(),
89 model_source,
90 custom_api_keys: config.agent.custom_api_keys.clone(),
91 checkpointing_enabled: config.agent.checkpointing.enabled,
92 checkpointing_storage_dir,
93 checkpointing_max_snapshots: config.agent.checkpointing.max_snapshots,
94 checkpointing_max_age_days: config.agent.checkpointing.max_age_days,
95 max_conversation_turns: config.agent.max_conversation_turns,
96 model_behavior: Some(config.model.clone()),
97 openai_chatgpt_auth: None,
98 }
99}
100
101pub fn resolve_checkpointing_storage_dir(
102 workspace: &Path,
103 storage_dir: Option<&str>,
104) -> Option<PathBuf> {
105 storage_dir.map(PathBuf::from).map(|candidate| {
106 if candidate.is_absolute() {
107 candidate
108 } else {
109 workspace.join(candidate)
110 }
111 })
112}
113
114pub fn provider_label(provider: &str, vt_cfg: Option<&VTCodeConfig>) -> String {
115 if let Some(vt_cfg) = vt_cfg {
116 return vt_cfg.provider_display_name(provider);
117 }
118
119 if provider.eq_ignore_ascii_case("codex") {
120 return "Codex".to_string();
121 }
122
123 Provider::from_str(provider)
124 .map(|resolved| resolved.label().to_string())
125 .unwrap_or_else(|_| provider.to_string())
126}
127
128fn resolve_provider(
129 cli_provider: Option<String>,
130 configured_provider: &str,
131 model: &str,
132 model_source: ModelSelectionSource,
133) -> String {
134 if let Some(provider) = cli_provider {
135 return provider;
136 }
137
138 if matches!(model_source, ModelSelectionSource::CliOverride)
139 && let Some(provider) = infer_provider(None, model)
140 {
141 return provider.to_string();
142 }
143
144 let configured_provider = configured_provider.trim();
145 if !configured_provider.is_empty() {
146 return configured_provider.to_owned();
147 }
148
149 infer_provider(None, model)
150 .map(|provider| provider.to_string())
151 .unwrap_or_else(|| defaults::DEFAULT_PROVIDER.to_owned())
152}
153
154fn provider_env_override() -> Option<String> {
155 std::env::var("VTCODE_PROVIDER")
156 .ok()
157 .or_else(|| std::env::var("provider").ok())
158 .map(|value| value.trim().to_owned())
159 .filter(|value| !value.is_empty())
160}
161
162#[cfg(test)]
163mod tests {
164 use super::*;
165 use clap::Parser;
166
167 #[test]
168 fn provider_resolution_prefers_configured_provider_for_config_model() {
169 let mut config = VTCodeConfig::default();
170 config.agent.provider = "zai".to_owned();
171 config.agent.default_model =
172 crate::config::constants::models::ollama::MINIMAX_M25_CLOUD.to_owned();
173
174 let args = Cli::parse_from(["vtcode"]);
175 let selection = resolve_runtime_model_selection(&args, &config);
176
177 assert_eq!(selection.provider, "zai");
178 assert_eq!(
179 selection.model_source,
180 ModelSelectionSource::WorkspaceConfig
181 );
182 }
183
184 #[test]
185 fn provider_resolution_infers_from_cli_model_without_cli_provider() {
186 let mut config = VTCodeConfig::default();
187 config.agent.provider = "zai".to_owned();
188
189 let args = Cli::parse_from([
190 "vtcode",
191 "--model",
192 crate::config::constants::models::ollama::MINIMAX_M25_CLOUD,
193 ]);
194 let selection = resolve_runtime_model_selection(&args, &config);
195
196 assert_eq!(selection.provider, "ollama");
197 assert_eq!(selection.model_source, ModelSelectionSource::CliOverride);
198 }
199
200 #[test]
201 fn provider_resolution_uses_cli_provider_when_present() {
202 let mut config = VTCodeConfig::default();
203 config.agent.provider = "zai".to_owned();
204
205 let args = Cli::parse_from([
206 "vtcode",
207 "--model",
208 crate::config::constants::models::ollama::MINIMAX_M25_CLOUD,
209 "--provider",
210 "minimax",
211 ]);
212 let selection = resolve_runtime_model_selection(&args, &config);
213
214 assert_eq!(selection.provider, "minimax");
215 }
216
217 #[test]
218 fn build_runtime_agent_config_uses_provider_default_api_key_env() {
219 let mut config = VTCodeConfig::default();
220 config.agent.api_key_env = defaults::DEFAULT_API_KEY_ENV.to_owned();
221
222 let args = Cli::parse_from(["vtcode", "--provider", "openai"]);
223 let selection = RuntimeModelSelection {
224 model: crate::config::constants::models::openai::GPT_5.to_owned(),
225 provider: "openai".to_owned(),
226 model_source: ModelSelectionSource::CliOverride,
227 };
228
229 let agent_config = build_runtime_agent_config(
230 &args,
231 &config,
232 PathBuf::from("/workspace"),
233 selection,
234 "test-key".to_owned(),
235 "dark".to_owned(),
236 );
237
238 assert_eq!(agent_config.api_key_env, "OPENAI_API_KEY");
239 }
240
241 #[test]
242 fn build_runtime_agent_config_respects_cli_api_key_env_override() {
243 let config = VTCodeConfig::default();
244 let args = Cli::parse_from([
245 "vtcode",
246 "--provider",
247 "openai",
248 "--api-key-env",
249 "CUSTOM_OPENAI_KEY",
250 ]);
251 let selection = RuntimeModelSelection {
252 model: crate::config::constants::models::openai::GPT_5.to_owned(),
253 provider: "openai".to_owned(),
254 model_source: ModelSelectionSource::CliOverride,
255 };
256
257 let agent_config = build_runtime_agent_config(
258 &args,
259 &config,
260 PathBuf::from("/workspace"),
261 selection,
262 "test-key".to_owned(),
263 "dark".to_owned(),
264 );
265
266 assert_eq!(agent_config.api_key_env, "CUSTOM_OPENAI_KEY");
267 }
268
269 #[test]
270 fn provider_label_uses_custom_provider_display_name() {
271 let mut config = VTCodeConfig::default();
272 config
273 .custom_providers
274 .push(vtcode_config::core::CustomProviderConfig {
275 name: "mycorp".to_string(),
276 display_name: "MyCorporateName".to_string(),
277 base_url: "https://llm.example/v1".to_string(),
278 api_key_env: "MYCORP_API_KEY".to_string(),
279 auth: None,
280 model: "gpt-5-mini".to_string(),
281 models: Vec::new(),
282 });
283
284 assert_eq!(provider_label("mycorp", Some(&config)), "MyCorporateName");
285 }
286
287 #[test]
288 fn resolve_checkpointing_storage_dir_preserves_absolute_path() {
289 let resolved = resolve_checkpointing_storage_dir(
290 Path::new("/workspace"),
291 Some("/tmp/vtcode-checkpoints"),
292 );
293
294 assert_eq!(resolved, Some(PathBuf::from("/tmp/vtcode-checkpoints")));
295 }
296
297 #[test]
298 fn build_runtime_agent_config_canonicalizes_relative_workspace() {
299 let temp = tempfile::TempDir::new().expect("temp dir");
300 let original_dir = std::env::current_dir().expect("current dir");
301 std::env::set_current_dir(temp.path()).expect("set current dir");
302
303 let config = VTCodeConfig::default();
304 let args = Cli::parse_from(["vtcode"]);
305 let selection = RuntimeModelSelection {
306 model: crate::config::constants::models::openai::GPT_5.to_owned(),
307 provider: "openai".to_owned(),
308 model_source: ModelSelectionSource::CliOverride,
309 };
310
311 let agent_config = build_runtime_agent_config(
312 &args,
313 &config,
314 PathBuf::from("."),
315 selection,
316 "test-key".to_owned(),
317 "dark".to_owned(),
318 );
319
320 std::env::set_current_dir(original_dir).expect("restore current dir");
321 let expected_workspace = std::fs::canonicalize(temp.path()).expect("canonical workspace");
322
323 assert_eq!(agent_config.workspace, expected_workspace);
324 }
325}