//! JSON file–backed `AppConfigRepository` with Claude credential auto-detection. use std::path::Path; use serde::{Deserialize, Serialize}; use zesdex_domain::cms::{ AppConfig, AppConfigRepository, ModelRole, ProviderConfig, RepositoryError, }; use crate::utils::write_json_atomic; /// File-based `AppConfigRepository` that reads/writes `app_config.json`. #[derive(Debug, Clone, Default)] pub struct JsonAppConfigRepository; impl JsonAppConfigRepository { pub fn new() -> Self { Self } } #[derive(Debug, Clone, Serialize, Deserialize)] struct ClaudeEnv { #[serde(alias = "ANTHROPIC_BASE_URL")] anthropic_base_url: Option, #[serde(alias = "ANTHROPIC_API_KEY")] anthropic_api_key: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] struct ClaudeSettings { env: Option, #[serde(alias = "customModel", alias = "model")] custom_model: Option, } fn claude_settings_from_file() -> Option { let path = dirs::home_dir()?.join(".claude").join("settings.json"); let content = std::fs::read_to_string(&path).ok()?; serde_json::from_str(&content).ok() } fn detect_claude_settings_provider() -> Option<(ProviderConfig, Option)> { let settings = claude_settings_from_file(); let file_creds = settings.as_ref().and_then(|s| { let env = s.env.as_ref()?; Some(( env.anthropic_base_url.clone()?, env.anthropic_api_key.clone()?, )) }); let env_creds = || -> Option<(String, String)> { let base_url = std::env::var("ANTHROPIC_BASE_URL").ok()?; let key = std::env::var("ANTHROPIC_API_KEY").ok()?; Some((base_url, key)) }; let custom_model = settings.and_then(|s| s.custom_model); let (base_url, key) = file_creds.or_else(env_creds)?; Some(( ProviderConfig { api_base: base_url, api_key_env: Some("ANTHROPIC_API_KEY".to_string()), default_model: custom_model.clone(), default_api_key: Some(key), }, custom_model, )) } /// Apply a detected Claude provider + custom model onto an `AppConfig`. /// /// Pure (no I/O) so it can be unit-tested. Flow: /// 1. Always `insert`s the "claude" provider (refreshing a possibly stale /// persisted entry with the current base URL + key from settings.json). /// 2. Registers known Claude model roles if missing. /// 3. Always sets `default_provider = "claude"` and /// `default_model = custom_model.unwrap_or("claude-opus-5")` so Opus /// is the default whenever `~/.claude/settings.json` is present. fn apply_claude_provider( cfg: &mut AppConfig, claude_provider: ProviderConfig, custom_model: Option, ) { cfg.providers.insert("claude".to_string(), claude_provider); let claude_models: [(&str, &str); 3] = [ ("claude-opus-5", "claude-opus-5"), ("claude-sonnet-5", "claude-sonnet-5"), ("claude-haiku-4-5", "claude-haiku-4-5-20251001"), ]; for (role_name, model_name) in &claude_models { cfg.model_roles .entry(role_name.to_string()) .or_insert(ModelRole { provider: "claude".to_string(), model: model_name.to_string(), max_tokens: Some(8192), context_window: Some(200_000), temperature: Some(0.7), }); } if let Some(custom) = &custom_model { cfg.model_roles.entry(custom.clone()).or_insert(ModelRole { provider: "claude".to_string(), model: custom.clone(), max_tokens: Some(8192), context_window: Some(200_000), temperature: Some(0.7), }); } // Always prefer the Claude provider + Opus model when settings.json // is present — this is the user's explicit custom endpoint choice. cfg.default_provider = "claude".to_string(); cfg.default_model = custom_model.unwrap_or_else(|| "claude-opus-5".to_string()); } impl AppConfigRepository for JsonAppConfigRepository { fn load(&self, base_dir: &Path) -> Result { let path = base_dir.join("app_config.json"); let mut cfg: AppConfig = match std::fs::read_to_string(&path) { Ok(s) => serde_json::from_str(&s)?, Err(e) if e.kind() == std::io::ErrorKind::NotFound => AppConfig::default(), Err(e) => return Err(RepositoryError::Io(e)), }; let defaults = AppConfig::default(); for (name, provider) in defaults.providers { cfg.providers.entry(name).or_insert(provider); } if let Some((claude_provider, custom_model)) = detect_claude_settings_provider() { apply_claude_provider(&mut cfg, claude_provider, custom_model); } Ok(cfg) } fn save(&self, base_dir: &Path, config: &AppConfig) -> Result<(), RepositoryError> { std::fs::create_dir_all(base_dir)?; let path = base_dir.join("app_config.json"); write_json_atomic(&path, config, None)?; Ok(()) } } #[cfg(test)] mod tests { use super::*; use std::collections::HashMap; fn claude_provider(base: &str, key: Option<&str>) -> ProviderConfig { ProviderConfig { api_base: base.to_string(), api_key_env: Some("ANTHROPIC_API_KEY".to_string()), default_model: Some("claude-opus-5".to_string()), default_api_key: key.map(|s| s.to_string()), } } #[test] fn claude_settings_parse_env() { let parsed: ClaudeSettings = serde_json::from_str( r#"{"env":{"ANTHROPIC_BASE_URL":"https://9router.example/v1","ANTHROPIC_API_KEY":"sk-test"}}"#, ) .unwrap(); let env = parsed.env.unwrap(); assert_eq!( env.anthropic_base_url.as_deref(), Some("https://9router.example/v1") ); assert_eq!(env.anthropic_api_key.as_deref(), Some("sk-test")); } #[test] fn apply_claude_refreshes_stale_provider_and_sets_opus_default() { // Simulate a previously-persisted app_config.json with a STALE claude // provider + non-opus default (e.g. user had switched provider). let mut cfg = AppConfig { providers: { let mut m = HashMap::new(); m.insert( "claude".to_string(), claude_provider("https://old.example/v1", Some("sk-old")), ); m }, model_roles: HashMap::new(), default_provider: "router".to_string(), default_model: "other-model".to_string(), default_context_window: 256_000, }; // Detect returned a fresh provider from ~/.claude/settings.json. apply_claude_provider( &mut cfg, claude_provider("https://9router.example/v1", Some("sk-new")), None, ); let claude = cfg.providers.get("claude").unwrap(); assert_eq!(claude.api_base, "https://9router.example/v1"); assert_eq!(claude.default_api_key.as_deref(), Some("sk-new")); // Insert (not or_insert) → stale entry refreshed. assert_eq!(cfg.default_provider, "claude"); assert_eq!(cfg.default_model, "claude-opus-5"); // Claude model roles registered. assert!(cfg.model_roles.contains_key("claude-opus-5")); assert!(cfg.model_roles.contains_key("claude-sonnet-5")); assert!(cfg.model_roles.contains_key("claude-haiku-4-5")); } #[test] fn apply_claude_honors_custom_model_from_settings() { let mut cfg = AppConfig::default(); apply_claude_provider( &mut cfg, claude_provider("https://9router.example/v1", Some("sk-new")), Some("claude-opus-5".to_string()), ); assert_eq!(cfg.default_model, "claude-opus-5"); assert!(cfg.model_roles.contains_key("claude-opus-5")); } #[test] fn detect_uses_env_creds_as_fallback() { // When ~/.claude/settings.json is absent/unreadable, the env-var // fallback should produce a "claude" provider. Set env vars, call // detect, and assert the resulting provider uses them. std::env::set_var("ANTHROPIC_BASE_URL", "https://env.example/v1"); std::env::set_var("ANTHROPIC_API_KEY", "sk-env"); match detect_claude_settings_provider() { Some((provider, _custom)) => { // If the real settings.json exists it wins (base could be the // real 9router URL); otherwise env creds are used. Either way, // the provider must have api_key_env pointing at ANTHROPIC_API_KEY. assert_eq!(provider.api_key_env.as_deref(), Some("ANTHROPIC_API_KEY")); } None => { // No file + no env (shouldn't happen since we just set env). panic!("expected env fallback to produce a provider"); } } std::env::remove_var("ANTHROPIC_BASE_URL"); std::env::remove_var("ANTHROPIC_API_KEY"); } }