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 #[test]
373 fn test_default_config() {
374 let config = AgentConfig::default();
375 assert!(config.base_url.is_none());
376 assert!(config.api_key.is_none());
377 assert!(config.model.is_none());
378 assert!(config.small_fast_model.is_none());
379 assert!(config.max_thinking_tokens.is_none());
380 assert!(!config.is_configured());
381 }
382
383 #[test]
384 fn test_to_env_vars() {
385 let config = AgentConfig {
386 base_url: Some("https://api.example.com".to_string()),
387 api_key: Some("secret-key".to_string()),
388 model: Some("claude-3".to_string()),
389 small_fast_model: None,
390 max_thinking_tokens: None,
391 };
392
393 let env = config.to_env_vars();
394 assert_eq!(
395 env.get("ANTHROPIC_BASE_URL").unwrap(),
396 "https://api.example.com"
397 );
398 assert_eq!(env.get("ANTHROPIC_API_KEY").unwrap(), "secret-key");
399 assert_eq!(env.get("ANTHROPIC_MODEL").unwrap(), "claude-3");
400 assert!(!env.contains_key("ANTHROPIC_SMALL_FAST_MODEL"));
401 }
402
403 #[test]
404 fn test_masked_api_key() {
405 let config = AgentConfig::default();
407 assert!(config.masked_api_key().is_none());
408
409 let config = AgentConfig {
411 api_key: Some("".to_string()),
412 ..Default::default()
413 };
414 assert_eq!(config.masked_api_key().unwrap(), "***");
415
416 let config = AgentConfig {
418 api_key: Some("a".to_string()),
419 ..Default::default()
420 };
421 assert_eq!(config.masked_api_key().unwrap(), "a***");
422
423 let config = AgentConfig {
425 api_key: Some("ab".to_string()),
426 ..Default::default()
427 };
428 assert_eq!(config.masked_api_key().unwrap(), "a***");
429
430 let config = AgentConfig {
432 api_key: Some("abc123".to_string()),
433 ..Default::default()
434 };
435 assert_eq!(config.masked_api_key().unwrap(), "a***3");
436
437 let config = AgentConfig {
439 api_key: Some("sk-ant-api03-12345-abcd".to_string()),
440 ..Default::default()
441 };
442 assert_eq!(config.masked_api_key().unwrap(), "sk-a***abcd");
443
444 let config = AgentConfig {
446 api_key: Some("sk-ant-api03-xxxx-xxxx-xxxx-xxxxxxxxxxx".to_string()),
447 ..Default::default()
448 };
449 let masked = config.masked_api_key().unwrap();
450 assert!(masked.starts_with("sk-a"));
451 assert!(masked.ends_with("xxxx"));
452 assert!(masked.contains("***"));
453 }
454
455 #[test]
456 fn test_is_configured() {
457 let mut config = AgentConfig::default();
458 assert!(!config.is_configured());
459
460 config.model = Some("test".to_string());
461 assert!(config.is_configured());
462 }
463
464 #[test]
465 fn test_max_thinking_tokens_config() {
466 let config = AgentConfig {
467 base_url: None,
468 api_key: None,
469 model: None,
470 small_fast_model: None,
471 max_thinking_tokens: Some(4096),
472 };
473
474 assert!(config.is_configured());
475 assert_eq!(config.max_thinking_tokens, Some(4096));
476 }
477
478 #[test]
479 #[serial_test::serial]
480 fn test_from_settings_or_env() {
481 let temp_base = std::env::temp_dir();
483 let temp_dir = temp_base.join("test_config_combined");
484 let settings_dir = temp_dir.join(".claude");
485
486 unsafe {
489 std::env::remove_var("ANTHROPIC_MODEL");
490 }
491 unsafe {
492 std::env::remove_var("ANTHROPIC_SMALL_FAST_MODEL");
493 }
494 unsafe {
495 std::env::remove_var("ANTHROPIC_BASE_URL");
496 }
497
498 drop(std::fs::remove_dir_all(&temp_dir));
500 std::fs::create_dir_all(&settings_dir).ok();
501
502 let settings_file = settings_dir.join("settings.json");
503 std::fs::write(
504 &settings_file,
505 r#"{
506 "model": "settings-model",
507 "smallFastModel": "settings-small-model",
508 "apiBaseUrl": "https://settings.api.com"
509 }"#,
510 )
511 .ok();
512
513 unsafe {
516 std::env::set_var("ANTHROPIC_MODEL", "env-model");
517 }
518
519 let config = AgentConfig::from_settings_or_env(&temp_dir);
520 assert_eq!(config.model, Some("env-model".to_string()));
521 assert_eq!(
522 config.small_fast_model,
523 Some("settings-small-model".to_string())
524 );
525 assert_eq!(
526 config.base_url,
527 Some("https://settings.api.com".to_string())
528 );
529
530 unsafe {
533 std::env::remove_var("ANTHROPIC_MODEL");
534 }
535 assert!(
536 std::env::var("ANTHROPIC_MODEL").is_err(),
537 "ANTHROPIC_MODEL should be removed"
538 );
539
540 let config2 = AgentConfig::from_settings_or_env(&temp_dir);
541 assert_eq!(config2.model, Some("settings-model".to_string()));
542 assert_eq!(
543 config2.small_fast_model,
544 Some("settings-small-model".to_string())
545 );
546
547 std::fs::remove_file(&settings_file).ok();
549 unsafe {
550 std::env::set_var("ANTHROPIC_MODEL", "env-only-model");
551 }
552
553 let config3 = AgentConfig::from_settings_or_env(&temp_dir);
554 assert_eq!(config3.model, Some("env-only-model".to_string()));
555 assert!(config3.small_fast_model.is_none());
556
557 unsafe {
559 std::env::remove_var("ANTHROPIC_MODEL");
560 }
561 drop(std::fs::remove_dir_all(&temp_dir));
562 }
563
564 #[test]
565 #[serial_test::serial]
566 fn test_from_settings_env_fallback() {
567 let temp_base = std::env::temp_dir();
569 let temp_dir = temp_base.join("test_config_env_fallback");
570 let settings_dir = temp_dir.join(".claude");
571
572 unsafe {
575 std::env::remove_var("ANTHROPIC_MODEL");
576 }
577 unsafe {
578 std::env::remove_var("ANTHROPIC_SMALL_FAST_MODEL");
579 }
580 unsafe {
581 std::env::remove_var("ANTHROPIC_BASE_URL");
582 }
583
584 drop(std::fs::remove_dir_all(&temp_dir));
586 std::fs::create_dir_all(&settings_dir).ok();
587
588 let settings_file = settings_dir.join("settings.json");
590 std::fs::write(
591 &settings_file,
592 r#"{
593 "env": {
594 "ANTHROPIC_MODEL": "env-settings-model",
595 "ANTHROPIC_SMALL_FAST_MODEL": "env-settings-small-model",
596 "ANTHROPIC_BASE_URL": "https://env-settings.api.com"
597 }
598 }"#,
599 )
600 .ok();
601
602 let config = AgentConfig::from_settings_or_env(&temp_dir);
603 assert_eq!(config.model, Some("env-settings-model".to_string()));
604 assert_eq!(
605 config.small_fast_model,
606 Some("env-settings-small-model".to_string())
607 );
608 assert_eq!(
609 config.base_url,
610 Some("https://env-settings.api.com".to_string())
611 );
612
613 drop(std::fs::remove_dir_all(&temp_dir));
615 }
616
617 #[test]
618 #[serial_test::serial]
619 fn test_from_settings_priority_order() {
620 let temp_base = std::env::temp_dir();
622 let temp_dir = temp_base.join("test_config_priority");
623 let settings_dir = temp_dir.join(".claude");
624
625 unsafe {
628 std::env::remove_var("ANTHROPIC_MODEL");
629 }
630 unsafe {
631 std::env::remove_var("ANTHROPIC_SMALL_FAST_MODEL");
632 }
633 unsafe {
634 std::env::remove_var("ANTHROPIC_BASE_URL");
635 }
636
637 drop(std::fs::remove_dir_all(&temp_dir));
638 std::fs::create_dir_all(&settings_dir).ok();
639
640 let settings_file = settings_dir.join("settings.json");
642 std::fs::write(
643 &settings_file,
644 r#"{
645 "model": "top-level-model",
646 "env": {
647 "ANTHROPIC_MODEL": "env-object-model"
648 }
649 }"#,
650 )
651 .ok();
652
653 let config1 = AgentConfig::from_settings_or_env(&temp_dir);
655 assert_eq!(config1.model, Some("top-level-model".to_string()));
656
657 unsafe {
659 std::env::set_var("ANTHROPIC_MODEL", "env-var-model");
660 }
661 let config2 = AgentConfig::from_settings_or_env(&temp_dir);
662 assert_eq!(config2.model, Some("env-var-model".to_string()));
663
664 unsafe {
666 std::env::remove_var("ANTHROPIC_MODEL");
667 }
668 drop(std::fs::remove_dir_all(&temp_dir));
669 }
670
671 #[test]
672 #[serial_test::serial]
673 fn test_always_thinking_enabled() {
674 let temp_base = std::env::temp_dir();
677 let temp_dir = temp_base.join("test_config_thinking");
678 let settings_dir = temp_dir.join(".claude");
679
680 drop(std::fs::remove_dir_all(&temp_dir));
682 unsafe {
683 std::env::remove_var("MAX_THINKING_TOKENS");
684 }
685
686 std::fs::create_dir_all(&settings_dir).ok();
687
688 let local_settings_file = settings_dir.join("settings.local.json");
690
691 std::fs::write(
693 &local_settings_file,
694 r#"{
695 "alwaysThinkingEnabled": true
696 }"#,
697 )
698 .ok();
699
700 let config1 = AgentConfig::from_settings_or_env(&temp_dir);
701 assert_eq!(config1.max_thinking_tokens, Some(20000));
702
703 std::fs::write(
705 &local_settings_file,
706 r#"{
707 "alwaysThinkingEnabled": false
708 }"#,
709 )
710 .ok();
711
712 let config2 = AgentConfig::from_settings_or_env(&temp_dir);
713 assert_eq!(config2.max_thinking_tokens, None);
714
715 std::fs::write(
717 &local_settings_file,
718 r#"{
719 "alwaysThinkingEnabled": true
720 }"#,
721 )
722 .ok();
723 unsafe {
724 std::env::set_var("MAX_THINKING_TOKENS", "8000");
725 }
726
727 let config3 = AgentConfig::from_settings_or_env(&temp_dir);
728 assert_eq!(config3.max_thinking_tokens, Some(8000));
729
730 unsafe {
732 std::env::remove_var("MAX_THINKING_TOKENS");
733 }
734 std::fs::write(
736 &local_settings_file,
737 r#"{"model": "test-model", "alwaysThinkingEnabled": false}"#,
738 )
739 .ok();
740
741 let config4 = AgentConfig::from_settings_or_env(&temp_dir);
742 assert_eq!(config4.max_thinking_tokens, None);
743
744 unsafe {
746 std::env::remove_var("MAX_THINKING_TOKENS");
747 }
748 drop(std::fs::remove_dir_all(&temp_dir));
749 }
750}