//! Application-level configuration: LLM providers, model roles, and defaults, //! persisted to `app_config.json` in the store directory. use serde::{Deserialize, Serialize}; use std::collections::HashMap; /// Top-level application config: registered providers, named model roles, /// and which provider/model to use by default. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AppConfig { pub providers: HashMap, pub model_roles: HashMap, pub default_provider: String, pub default_model: String, pub default_context_window: u32, } /// Connection details for a single LLM provider (base URL, API key source). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ProviderConfig { pub api_base: String, pub api_key_env: Option, pub default_model: Option, pub default_api_key: Option, } /// A named role (e.g. "default") mapping to a specific provider/model and /// its generation parameters. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ModelRole { pub provider: String, pub model: String, pub max_tokens: Option, pub context_window: Option, pub temperature: Option, } impl Default for AppConfig { fn default() -> Self { let mut providers = HashMap::new(); providers.insert("zen".to_string(), ProviderConfig { api_base: "https://opencode.ai/zen/v1".to_string(), api_key_env: Some("API_KEY".to_string()), default_model: Some("deepseek-v4-flash-free".to_string()), default_api_key: None, }); providers.insert("router".to_string(), ProviderConfig { api_base: "https://9router.asepharyana.my.id/v1".to_string(), api_key_env: Some("ROUTER_API_KEY".to_string()), default_model: Some("claude-opus-4-8".to_string()), default_api_key: None, }); let mut model_roles = HashMap::new(); model_roles.insert("default".to_string(), ModelRole { provider: "zen".to_string(), model: "deepseek-v4-flash-free".to_string(), max_tokens: None, context_window: None, temperature: Some(0.7), }); AppConfig { providers, model_roles, default_provider: "zen".to_string(), default_model: "deepseek-v4-flash-free".to_string(), default_context_window: 256_000, } } } impl AppConfig { /// Load app config from disk, falling back to defaults on any failure. /// /// Flow: read `/app_config.json` → JSON-parse → on missing file /// or parse error, use `Self::default()` → merge any default providers /// not already present in the loaded config. /// /// Why: the merge step lets newly-added default providers (e.g. a new /// release adding a provider) appear even in configs saved by older /// versions, without clobbering user-edited entries with the same name. /// /// Return: a fully-populated `AppConfig`, never fails. pub fn load() -> Self { let store = super::store::Store::new(); let path = store.base_dir.join("app_config.json"); let mut cfg: AppConfig = match std::fs::read_to_string(&path) { Ok(s) => match serde_json::from_str(&s) { Ok(c) => c, Err(e) => { tracing::warn!( "warning: failed to parse config file '{}': {}. Loading defaults.", path.display(), e ); Self::default() } }, Err(_) => Self::default(), }; // Merge any default providers not present in the loaded config let defaults = Self::default(); for (name, provider) in defaults.providers { cfg.providers.entry(name).or_insert(provider); } // Auto-detect provider from ~/.claude/settings.json if let Some(claude_provider) = detect_claude_settings_provider() { cfg.providers.entry("claude".to_string()).or_insert(claude_provider); // Register known Claude models as named model roles let claude_models = [ ("claude-opus-4-8", "claude-opus-4-8"), ("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), }); } // Set as default provider only if user hasn't picked a custom default if cfg.default_provider == defaults.default_provider { cfg.default_provider = "claude".to_string(); cfg.default_model = "claude-opus-4-8".to_string(); } } cfg } } /// Configuration structure inside `~/.claude/settings.json`. #[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, } /// Return a `ProviderConfig` for the Claude provider, checking both /// `~/.claude/settings.json` and the process environment. /// /// Flow: try the file (`env.ANTHROPIC_BASE_URL` + `env.ANTHROPIC_API_KEY`) /// first → fall back to the `ANTHROPIC_BASE_URL` / `ANTHROPIC_API_KEY` env /// vars → if neither source has both values, return `None`. /// /// Why: Claude Code may inject credentials via env vars (OAuth session) rather /// than through its settings file, so reading only the file misses them. fn detect_claude_settings_provider() -> Option { // Prefer the file, then fall back to env vars. let (base_url, key) = claude_credentials_from_file() .or_else(claude_credentials_from_env)?; Some(ProviderConfig { api_base: base_url, // Keep the env-var name so runtime env overrides still work. api_key_env: Some("ANTHROPIC_API_KEY".to_string()), default_model: None, // Store the key read from the file as a direct fallback. // Without this, subagent/engine.rs resolve_provider_config() falls // through to std::env::var("ANTHROPIC_API_KEY") which is only // injected into the Claude Code process — not into zesdex. Workflow // nodes therefore got an empty key and failed with // "no API key configured for provider 'claude'", even though the // main agent succeeded (it has a DEFAULT_API_KEY fallback that // subagents intentionally do not have). default_api_key: Some(key), }) } /// Try to read Claude credentials from `~/.claude/settings.json`'s `env` block. fn claude_credentials_from_file() -> Option<(String, String)> { let path = dirs::home_dir()?.join(".claude").join("settings.json"); let content = std::fs::read_to_string(&path).ok()?; let settings: ClaudeSettings = serde_json::from_str(&content).ok()?; let env = settings.env?; let base_url = env.anthropic_base_url?; let key = env.anthropic_api_key?; Some((base_url, key)) } /// Try to read Claude credentials from `ANTHROPIC_BASE_URL` / /// `ANTHROPIC_API_KEY` environment variables. fn claude_credentials_from_env() -> 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)) } #[cfg(test)] mod tests { use super::*; #[test] fn claude_credentials_from_env_resolves_real_env_vars() { // In the test runner's environment ANTHROPIC_BASE_URL and // ANTHROPIC_API_KEY may or may not be set — we only verify that // the function returns Some(..) when both are present. let Some((b, k)) = claude_credentials_from_env() else { // Not an error: CI / local without the vars. return; }; assert!(!b.is_empty(), "ANTHROPIC_BASE_URL must not be empty"); assert!(!k.is_empty(), "ANTHROPIC_API_KEY must not be empty"); } }