1use std::collections::HashMap;
4
5#[derive(Debug, Clone, Default)]
21pub struct AgentConfig {
22 pub base_url: Option<String>,
26
27 pub api_key: Option<String>,
31
32 pub model: Option<String>,
36
37 pub small_fast_model: Option<String>,
41
42 pub max_thinking_tokens: Option<u32>,
52}
53
54impl AgentConfig {
55 pub fn new() -> Self {
57 Self::default()
58 }
59
60 pub fn from_env() -> Self {
70 let api_key = std::env::var("ANTHROPIC_API_KEY")
72 .ok()
73 .or_else(|| std::env::var("ANTHROPIC_AUTH_TOKEN").ok());
74
75 let max_thinking_tokens = std::env::var("MAX_THINKING_TOKENS")
77 .ok()
78 .and_then(|s| s.parse::<u32>().ok());
79
80 Self {
81 base_url: std::env::var("ANTHROPIC_BASE_URL").ok(),
82 api_key,
83 model: std::env::var("ANTHROPIC_MODEL").ok(),
84 small_fast_model: std::env::var("ANTHROPIC_SMALL_FAST_MODEL").ok(),
85 max_thinking_tokens,
86 }
87 }
88
89 pub fn from_settings_or_env(project_dir: &std::path::Path) -> Self {
138 use crate::settings::SettingsManager;
139
140 let settings = SettingsManager::new(project_dir)
142 .map(|m| m.settings().clone())
143 .unwrap_or_default();
144
145 let has_env_settings = settings.env.as_ref().map_or(false, |env| !env.is_empty());
148 tracing::trace!(
149 has_user_settings =
150 settings.model.is_some() || settings.api_base_url.is_some() || has_env_settings,
151 "Settings files discovered"
152 );
153
154 let has_model_env = std::env::var("ANTHROPIC_MODEL").is_ok();
156 let has_base_url_env = std::env::var("ANTHROPIC_BASE_URL").is_ok();
157 let has_small_fast_model_env = std::env::var("ANTHROPIC_SMALL_FAST_MODEL").is_ok();
158 let has_max_thinking_tokens_env = std::env::var("MAX_THINKING_TOKENS").is_ok();
159
160 let has_model_settings = settings.model.is_some();
162 let has_base_url_settings = settings.api_base_url.is_some();
163 let has_small_fast_model_settings = settings.small_fast_model.is_some();
164 let has_model_env_settings = settings
165 .env
166 .as_ref()
167 .and_then(|env| env.get("ANTHROPIC_MODEL"))
168 .is_some();
169 let has_base_url_env_settings = settings
170 .env
171 .as_ref()
172 .and_then(|env| env.get("ANTHROPIC_BASE_URL"))
173 .is_some();
174 let has_small_fast_model_env_settings = settings
175 .env
176 .as_ref()
177 .and_then(|env| env.get("ANTHROPIC_SMALL_FAST_MODEL"))
178 .is_some();
179 let has_max_thinking_tokens_env_settings = settings
180 .env
181 .as_ref()
182 .and_then(|env| env.get("MAX_THINKING_TOKENS"))
183 .is_some();
184
185 let base_url = std::env::var("ANTHROPIC_BASE_URL")
187 .ok()
188 .or_else(|| settings.api_base_url.map(|u| u.to_string()))
189 .or_else(|| {
190 settings
191 .env
192 .as_ref()
193 .and_then(|env| env.get("ANTHROPIC_BASE_URL").cloned())
194 });
195
196 let model = std::env::var("ANTHROPIC_MODEL")
197 .ok()
198 .or_else(|| settings.model.map(|m| m.to_string()))
199 .or_else(|| {
200 settings
201 .env
202 .as_ref()
203 .and_then(|env| env.get("ANTHROPIC_MODEL").cloned())
204 });
205
206 let small_fast_model = std::env::var("ANTHROPIC_SMALL_FAST_MODEL")
207 .ok()
208 .or_else(|| settings.small_fast_model.map(|m| m.to_string()))
209 .or_else(|| {
210 settings
211 .env
212 .as_ref()
213 .and_then(|env| env.get("ANTHROPIC_SMALL_FAST_MODEL").cloned())
214 });
215
216 let api_key = std::env::var("ANTHROPIC_API_KEY")
219 .ok()
220 .or_else(|| std::env::var("ANTHROPIC_AUTH_TOKEN").ok());
221
222 const DEFAULT_MAX_THINKING_TOKENS: u32 = 20000;
224
225 let always_thinking_enabled = settings.always_thinking_enabled.unwrap_or(false);
227
228 let max_thinking_tokens = std::env::var("MAX_THINKING_TOKENS")
229 .ok()
230 .and_then(|s| s.parse::<u32>().ok())
231 .or_else(|| {
232 settings.env.as_ref().and_then(|env| {
233 env.get("MAX_THINKING_TOKENS")
234 .and_then(|s| s.parse::<u32>().ok())
235 })
236 })
237 .or_else(|| {
238 if always_thinking_enabled {
241 Some(DEFAULT_MAX_THINKING_TOKENS)
242 } else {
243 None
244 }
245 });
246
247 let config = Self {
248 base_url,
249 api_key,
250 model,
251 small_fast_model,
252 max_thinking_tokens,
253 };
254
255 tracing::info!(
257 model = ?config.model,
258 model_source = if has_model_env { "env" } else if has_model_settings { "settings" } else if has_model_env_settings { "settings.env" } else { "default" },
259 base_url = ?config.base_url,
260 base_url_source = if has_base_url_env { "env" } else if has_base_url_settings { "settings" } else if has_base_url_env_settings { "settings.env" } else { "default" },
261 small_fast_model = ?config.small_fast_model,
262 small_fast_model_source = if has_small_fast_model_env { "env" } else if has_small_fast_model_settings { "settings" } else if has_small_fast_model_env_settings { "settings.env" } else { "default" },
263 max_thinking_tokens = ?config.max_thinking_tokens,
264 max_thinking_tokens_source = if has_max_thinking_tokens_env { "env" } else if has_max_thinking_tokens_env_settings { "settings.env" } else if always_thinking_enabled { "alwaysThinkingEnabled" } else { "default" },
265 always_thinking_enabled = always_thinking_enabled,
266 api_key = ?config.masked_api_key(),
267 "Configuration loaded (priority: env > settings.{{top-level, env}} > default)"
268 );
269
270 config
271 }
272
273 pub fn is_configured(&self) -> bool {
275 self.base_url.is_some()
276 || self.api_key.is_some()
277 || self.model.is_some()
278 || self.small_fast_model.is_some()
279 || self.max_thinking_tokens.is_some()
280 }
281
282 pub fn to_env_vars(&self) -> HashMap<String, String> {
287 let mut env = HashMap::new();
288
289 if let Some(ref url) = self.base_url {
290 env.insert("ANTHROPIC_BASE_URL".to_string(), url.clone());
291 }
292 if let Some(ref key) = self.api_key {
294 env.insert("ANTHROPIC_API_KEY".to_string(), key.clone());
295 }
296 if let Some(ref model) = self.model {
297 env.insert("ANTHROPIC_MODEL".to_string(), model.clone());
298 }
299 if let Some(ref model) = self.small_fast_model {
300 env.insert("ANTHROPIC_SMALL_FAST_MODEL".to_string(), model.clone());
301 }
302
303 env
304 }
305
306 pub fn masked_api_key(&self) -> Option<String> {
313 self.api_key.as_ref().map(|key| {
314 let key = key.as_str();
315 if key.is_empty() {
316 "***".to_string()
317 } else if key.len() <= 2 {
318 format!("{}***", &key[..1])
320 } else if key.len() <= 8 {
321 format!("{}***{}", &key[..1], &key[key.len() - 1..])
323 } else {
324 format!("{}***{}", &key[..4], &key[key.len() - 4..])
326 }
327 })
328 }
329
330 pub fn apply_to_options(&self, options: &mut claude_code_agent_sdk::ClaudeAgentOptions) {
334 if let Some(ref model) = self.model {
336 options.model = Some(model.clone());
337 }
338
339 if let Some(ref fallback) = self.small_fast_model {
341 options.fallback_model = Some(fallback.clone());
342 }
343
344 if let Some(tokens) = self.max_thinking_tokens {
346 options.max_thinking_tokens = Some(tokens);
347 }
348
349 let env_vars = self.to_env_vars();
351 if !env_vars.is_empty() {
352 options.env = env_vars;
353 }
354
355 tracing::debug!(
357 model = ?self.model,
358 fallback_model = ?self.small_fast_model,
359 base_url = ?self.base_url,
360 max_thinking_tokens = ?self.max_thinking_tokens,
361 api_key = ?self.masked_api_key(),
362 env_vars_count = options.env.len(),
363 "Agent configuration applied to options"
364 );
365 }
366}
367
368#[cfg(test)]
369mod tests {
370 use super::*;
371
372 struct EnvGuard {
375 vars: Vec<(String, Option<String>)>,
376 }
377
378 impl EnvGuard {
379 fn new(var_names: &[&str]) -> Self {
380 let vars = var_names
381 .iter()
382 .map(|&name| {
383 let original = std::env::var(name).ok();
384 unsafe {
387 std::env::remove_var(name);
388 }
389 (name.to_string(), original)
390 })
391 .collect();
392 Self { vars }
393 }
394 }
395
396 impl Drop for EnvGuard {
397 fn drop(&mut self) {
398 for (name, original) in &self.vars {
400 unsafe {
402 match original {
403 Some(val) => std::env::set_var(name, val),
404 None => std::env::remove_var(name),
405 }
406 }
407 }
408 }
409 }
410
411 #[test]
412 fn test_default_config() {
413 let config = AgentConfig::default();
414 assert!(config.base_url.is_none());
415 assert!(config.api_key.is_none());
416 assert!(config.model.is_none());
417 assert!(config.small_fast_model.is_none());
418 assert!(config.max_thinking_tokens.is_none());
419 assert!(!config.is_configured());
420 }
421
422 #[test]
423 fn test_to_env_vars() {
424 let config = AgentConfig {
425 base_url: Some("https://api.example.com".to_string()),
426 api_key: Some("secret-key".to_string()),
427 model: Some("claude-3".to_string()),
428 small_fast_model: None,
429 max_thinking_tokens: None,
430 };
431
432 let env = config.to_env_vars();
433 assert_eq!(
434 env.get("ANTHROPIC_BASE_URL").unwrap(),
435 "https://api.example.com"
436 );
437 assert_eq!(env.get("ANTHROPIC_API_KEY").unwrap(), "secret-key");
438 assert_eq!(env.get("ANTHROPIC_MODEL").unwrap(), "claude-3");
439 assert!(!env.contains_key("ANTHROPIC_SMALL_FAST_MODEL"));
440 }
441
442 #[test]
443 fn test_masked_api_key() {
444 let config = AgentConfig::default();
446 assert!(config.masked_api_key().is_none());
447
448 let config = AgentConfig {
450 api_key: Some("".to_string()),
451 ..Default::default()
452 };
453 assert_eq!(config.masked_api_key().unwrap(), "***");
454
455 let config = AgentConfig {
457 api_key: Some("a".to_string()),
458 ..Default::default()
459 };
460 assert_eq!(config.masked_api_key().unwrap(), "a***");
461
462 let config = AgentConfig {
464 api_key: Some("ab".to_string()),
465 ..Default::default()
466 };
467 assert_eq!(config.masked_api_key().unwrap(), "a***");
468
469 let config = AgentConfig {
471 api_key: Some("abc123".to_string()),
472 ..Default::default()
473 };
474 assert_eq!(config.masked_api_key().unwrap(), "a***3");
475
476 let config = AgentConfig {
478 api_key: Some("sk-ant-api03-12345-abcd".to_string()),
479 ..Default::default()
480 };
481 assert_eq!(config.masked_api_key().unwrap(), "sk-a***abcd");
482
483 let config = AgentConfig {
485 api_key: Some("sk-ant-api03-xxxx-xxxx-xxxx-xxxxxxxxxxx".to_string()),
486 ..Default::default()
487 };
488 let masked = config.masked_api_key().unwrap();
489 assert!(masked.starts_with("sk-a"));
490 assert!(masked.ends_with("xxxx"));
491 assert!(masked.contains("***"));
492 }
493
494 #[test]
495 fn test_is_configured() {
496 let mut config = AgentConfig::default();
497 assert!(!config.is_configured());
498
499 config.model = Some("test".to_string());
500 assert!(config.is_configured());
501 }
502
503 #[test]
504 fn test_max_thinking_tokens_config() {
505 let config = AgentConfig {
506 base_url: None,
507 api_key: None,
508 model: None,
509 small_fast_model: None,
510 max_thinking_tokens: Some(4096),
511 };
512
513 assert!(config.is_configured());
514 assert_eq!(config.max_thinking_tokens, Some(4096));
515 }
516
517 #[test]
518 #[serial_test::serial]
519 fn test_from_settings_or_env() {
520 let _guard = EnvGuard::new(&[
522 "ANTHROPIC_MODEL",
523 "ANTHROPIC_SMALL_FAST_MODEL",
524 "ANTHROPIC_BASE_URL",
525 ]);
526
527 let temp_base = std::env::temp_dir();
529 let temp_dir = temp_base.join("test_config_combined");
530 let settings_dir = temp_dir.join(".claude");
531
532 drop(std::fs::remove_dir_all(&temp_dir));
534 std::fs::create_dir_all(&settings_dir).ok();
535
536 let settings_file = settings_dir.join("settings.json");
537 std::fs::write(
538 &settings_file,
539 r#"{
540 "model": "settings-model",
541 "smallFastModel": "settings-small-model",
542 "apiBaseUrl": "https://settings.api.com"
543 }"#,
544 )
545 .ok();
546
547 unsafe {
550 std::env::set_var("ANTHROPIC_MODEL", "env-model");
551 }
552
553 let config = AgentConfig::from_settings_or_env(&temp_dir);
554 assert_eq!(config.model, Some("env-model".to_string()));
555 assert_eq!(
556 config.small_fast_model,
557 Some("settings-small-model".to_string())
558 );
559 assert_eq!(
560 config.base_url,
561 Some("https://settings.api.com".to_string())
562 );
563
564 unsafe {
567 std::env::remove_var("ANTHROPIC_MODEL");
568 }
569 assert!(
570 std::env::var("ANTHROPIC_MODEL").is_err(),
571 "ANTHROPIC_MODEL should be removed"
572 );
573
574 let config2 = AgentConfig::from_settings_or_env(&temp_dir);
575 assert_eq!(config2.model, Some("settings-model".to_string()));
576 assert_eq!(
577 config2.small_fast_model,
578 Some("settings-small-model".to_string())
579 );
580
581 std::fs::remove_file(&settings_file).ok();
583 unsafe {
584 std::env::set_var("ANTHROPIC_MODEL", "env-only-model");
585 }
586
587 let config3 = AgentConfig::from_settings_or_env(&temp_dir);
588 assert_eq!(config3.model, Some("env-only-model".to_string()));
589 assert!(config3.small_fast_model.is_none());
590
591 drop(std::fs::remove_dir_all(&temp_dir));
593 }
594
595 #[test]
596 #[serial_test::serial]
597 fn test_from_settings_env_fallback() {
598 let _guard = EnvGuard::new(&[
612 "ANTHROPIC_MODEL",
613 "ANTHROPIC_SMALL_FAST_MODEL",
614 "ANTHROPIC_BASE_URL",
615 ]);
616
617 let temp_base = std::env::temp_dir();
618 let temp_dir = temp_base.join("test_config_env_fallback");
619 let settings_dir = temp_dir.join(".claude");
620
621 drop(std::fs::remove_dir_all(&temp_dir));
623 std::fs::create_dir_all(&settings_dir).ok();
624
625 let settings_file = settings_dir.join("settings.local.json");
632 std::fs::write(
633 &settings_file,
634 r#"{
635 "model": "local-model",
636 "smallFastModel": "local-small-model",
637 "apiBaseUrl": "https://local.api.com"
638 }"#,
639 )
640 .ok();
641
642 let config = AgentConfig::from_settings_or_env(&temp_dir);
643 assert_eq!(config.model, Some("local-model".to_string()));
645 assert_eq!(
646 config.small_fast_model,
647 Some("local-small-model".to_string())
648 );
649 assert_eq!(config.base_url, Some("https://local.api.com".to_string()));
650
651 drop(std::fs::remove_dir_all(&temp_dir));
653 }
654
655 #[test]
656 #[serial_test::serial]
657 fn test_from_settings_priority_order() {
658 let _guard = EnvGuard::new(&[
661 "ANTHROPIC_MODEL",
662 "ANTHROPIC_SMALL_FAST_MODEL",
663 "ANTHROPIC_BASE_URL",
664 ]);
665
666 let temp_base = std::env::temp_dir();
667 let temp_dir = temp_base.join("test_config_priority");
668 let settings_dir = temp_dir.join(".claude");
669
670 drop(std::fs::remove_dir_all(&temp_dir));
671 std::fs::create_dir_all(&settings_dir).ok();
672
673 let settings_file = settings_dir.join("settings.json");
675 std::fs::write(
676 &settings_file,
677 r#"{
678 "model": "top-level-model",
679 "env": {
680 "ANTHROPIC_MODEL": "env-object-model"
681 }
682 }"#,
683 )
684 .ok();
685
686 let config1 = AgentConfig::from_settings_or_env(&temp_dir);
688 assert_eq!(config1.model, Some("top-level-model".to_string()));
689
690 unsafe {
692 std::env::set_var("ANTHROPIC_MODEL", "env-var-model");
693 }
694 let config2 = AgentConfig::from_settings_or_env(&temp_dir);
695 assert_eq!(config2.model, Some("env-var-model".to_string()));
696
697 drop(std::fs::remove_dir_all(&temp_dir));
699 }
700
701 #[test]
702 #[serial_test::serial]
703 fn test_always_thinking_enabled() {
704 let _guard = EnvGuard::new(&["MAX_THINKING_TOKENS"]);
707
708 let temp_base = std::env::temp_dir();
710 let temp_dir = temp_base.join("test_config_thinking");
711 let settings_dir = temp_dir.join(".claude");
712
713 drop(std::fs::remove_dir_all(&temp_dir));
715
716 std::fs::create_dir_all(&settings_dir).ok();
717
718 let local_settings_file = settings_dir.join("settings.local.json");
720
721 std::fs::write(
723 &local_settings_file,
724 r#"{
725 "alwaysThinkingEnabled": true
726 }"#,
727 )
728 .ok();
729
730 let config1 = AgentConfig::from_settings_or_env(&temp_dir);
731 assert_eq!(config1.max_thinking_tokens, Some(20000));
732
733 std::fs::write(
735 &local_settings_file,
736 r#"{
737 "alwaysThinkingEnabled": false
738 }"#,
739 )
740 .ok();
741
742 let config2 = AgentConfig::from_settings_or_env(&temp_dir);
743 assert_eq!(config2.max_thinking_tokens, None);
744
745 std::fs::write(
747 &local_settings_file,
748 r#"{
749 "alwaysThinkingEnabled": true
750 }"#,
751 )
752 .ok();
753 unsafe {
754 std::env::set_var("MAX_THINKING_TOKENS", "8000");
755 }
756
757 let config3 = AgentConfig::from_settings_or_env(&temp_dir);
758 assert_eq!(config3.max_thinking_tokens, Some(8000));
759
760 unsafe {
762 std::env::remove_var("MAX_THINKING_TOKENS");
763 }
764 std::fs::write(
766 &local_settings_file,
767 r#"{"model": "test-model", "alwaysThinkingEnabled": false}"#,
768 )
769 .ok();
770
771 let config4 = AgentConfig::from_settings_or_env(&temp_dir);
772 assert_eq!(config4.max_thinking_tokens, None);
773
774 drop(std::fs::remove_dir_all(&temp_dir));
776 }
777}