diff --git a/rust/crates/api/src/providers/anthropic.rs b/rust/crates/api/src/providers/anthropic.rs index 73a644aaf5..73236700bd 100644 --- a/rust/crates/api/src/providers/anthropic.rs +++ b/rust/crates/api/src/providers/anthropic.rs @@ -616,7 +616,16 @@ fn jitter_for_base(base: Duration) -> Duration { } impl AuthSource { + /// Resolve Anthropic credentials using the 3-tier resolution chain: + /// 1. Environment variables (`ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN`) + /// 2. `.env` file in the current working directory + /// 3. Stored provider config in `~/.claw/settings.json` + /// + /// Tier 1+2 are checked via `read_env_non_empty`. Tier 3 reads + /// the `provider.apiKey` field from settings.json when the stored + /// provider kind is "anthropic". pub fn from_env_or_saved() -> Result { + // Tier 1+2: environment variables (includes .env fallback) if let Some(api_key) = read_env_non_empty("ANTHROPIC_API_KEY")? { return match read_env_non_empty("ANTHROPIC_AUTH_TOKEN")? { Some(bearer_token) => Ok(Self::ApiKeyAndBearer { @@ -629,6 +638,21 @@ impl AuthSource { if let Some(bearer_token) = read_env_non_empty("ANTHROPIC_AUTH_TOKEN")? { return Ok(Self::BearerToken(bearer_token)); } + // Tier 3: stored config in ~/.claw/settings.json + if let Some(api_key) = super::read_env_or_config("ANTHROPIC_API_KEY", "anthropic") { + // Stored config always provides an API key (not a bearer token). + // If an env-only auth token exists, combine both. + let auth_token = read_env_non_empty("ANTHROPIC_AUTH_TOKEN") + .ok() + .and_then(std::convert::identity); + return match auth_token { + Some(bearer_token) => Ok(Self::ApiKeyAndBearer { + api_key, + bearer_token, + }), + None => Ok(Self::ApiKey(api_key)), + }; + } Err(anthropic_missing_credentials()) } } @@ -649,14 +673,15 @@ pub fn resolve_saved_oauth_token(config: &OAuthConfig) -> Result Result { Ok(read_env_non_empty("ANTHROPIC_API_KEY")?.is_some() - || read_env_non_empty("ANTHROPIC_AUTH_TOKEN")?.is_some()) + || read_env_non_empty("ANTHROPIC_AUTH_TOKEN")?.is_some() + || super::read_env_or_config("ANTHROPIC_API_KEY", "anthropic").is_some()) } pub fn resolve_startup_auth_source(load_oauth_config: F) -> Result where F: FnOnce() -> Result, ApiError>, { - let _ = load_oauth_config; + // Tier 1+2: environment variables (includes .env fallback) if let Some(api_key) = read_env_non_empty("ANTHROPIC_API_KEY")? { return match read_env_non_empty("ANTHROPIC_AUTH_TOKEN")? { Some(bearer_token) => Ok(AuthSource::ApiKeyAndBearer { @@ -669,6 +694,13 @@ where if let Some(bearer_token) = read_env_non_empty("ANTHROPIC_AUTH_TOKEN")? { return Ok(AuthSource::BearerToken(bearer_token)); } + // Tier 3: stored config in ~/.claw/settings.json + if let Some(api_key) = super::read_env_or_config("ANTHROPIC_API_KEY", "anthropic") { + let _ = load_oauth_config; // kept for API compatibility + return Ok(AuthSource::ApiKey(api_key)); + } + // Future: resolve OAuth token from saved config + let _ = load_oauth_config; Err(anthropic_missing_credentials()) } @@ -737,11 +769,7 @@ fn now_unix_timestamp() -> u64 { } fn read_env_non_empty(key: &str) -> Result, ApiError> { - match std::env::var(key) { - Ok(value) if !value.is_empty() => Ok(Some(value)), - Ok(_) | Err(std::env::VarError::NotPresent) => Ok(super::dotenv_value(key)), - Err(error) => Err(ApiError::from(error)), - } + super::read_env_non_empty(key) } #[cfg(test)] @@ -762,7 +790,7 @@ fn read_auth_token() -> Option { #[must_use] pub fn read_base_url() -> String { - std::env::var("ANTHROPIC_BASE_URL").unwrap_or_else(|_| DEFAULT_BASE_URL.to_string()) + super::resolve_base_url("ANTHROPIC_BASE_URL", "anthropic", DEFAULT_BASE_URL) } fn request_id_from_headers(headers: &reqwest::header::HeaderMap) -> Option { diff --git a/rust/crates/api/src/providers/mod.rs b/rust/crates/api/src/providers/mod.rs index f8fe624421..30a29269d0 100644 --- a/rust/crates/api/src/providers/mod.rs +++ b/rust/crates/api/src/providers/mod.rs @@ -698,6 +698,66 @@ fn estimate_serialized_tokens(value: &T) -> u32 { .map_or(0, |bytes| (bytes.len() / 4 + 1) as u32) } +/// 3-tier credential resolution for a single config key: +/// 1. Environment variable (highest priority, immediate override) +/// 2. `.env` file in the current working directory +/// 3. Stored provider config in `~/.claw/settings.json` (lowest priority) +/// +/// Returns `None` when no tier produces a non-empty value. This is the +/// core of the provider config fallback that the setup wizard depends +/// on — credentials saved by the wizard are read from tier 3 when the +/// user has not set env vars. +pub fn read_env_or_config(env_var: &str, provider_kind: &str) -> Option { + // Tier 1: real process environment + if let Ok(value) = std::env::var(env_var) { + if !value.is_empty() { + return Some(value); + } + } + // Tier 2: .env file in the current working directory + if let Some(value) = dotenv_value(env_var) { + if !value.is_empty() { + return Some(value); + } + } + // Tier 3: stored config in ~/.claw/settings.json + read_provider_config_value(provider_kind, env_var) +} + +/// Read a single credential value from the stored provider config in +/// `~/.claw/settings.json`. Maps the env var name to the correct field +/// in the `provider` JSON object: +/// - `ANTHROPIC_API_KEY` → `provider.apiKey` (when kind is "anthropic") +/// - `XAI_API_KEY` → `provider.apiKey` (when kind is "xai") +/// - `OPENAI_API_KEY` → `provider.apiKey` (when kind is "openai") +/// - `DASHSCOPE_API_KEY` → `provider.apiKey` (when kind is "dashscope") +fn read_provider_config_value(provider_kind: &str, _env_var: &str) -> Option { + let cwd = std::env::current_dir().unwrap_or_default(); + let config = runtime::ConfigLoader::default_for(&cwd).load().ok()?; + let provider = config.provider(); + // The stored kind must match the provider we're looking up credentials + // for, otherwise we'd return an xAI key for the OpenAI provider, etc. + let stored_kind = provider.kind()?; + if stored_kind != provider_kind { + return None; + } + provider.api_key().map(ToOwned::to_owned) +} + +/// Read the stored base URL for a provider from `~/.claw/settings.json`. +/// Returns `None` when the stored provider kind doesn't match or when +/// no base URL override was saved. +pub fn read_base_url_from_config(provider_kind: &str) -> Option { + let cwd = std::env::current_dir().unwrap_or_default(); + let config = runtime::ConfigLoader::default_for(&cwd).load().ok()?; + let provider = config.provider(); + let stored_kind = provider.kind()?; + if stored_kind != provider_kind { + return None; + } + provider.base_url().map(ToOwned::to_owned) +} + /// Env var names used by other provider backends. When Anthropic auth /// resolution fails we sniff these so we can hint the user that their /// credentials probably belong to a different provider and suggest the @@ -811,6 +871,44 @@ pub(crate) fn load_dotenv_file( Some(parse_dotenv(&content)) } +/// Read an environment variable, falling back to `.env` file lookup. +/// Returns `None` when neither the process environment nor the `.env` file +/// provides a non-empty value for `key`. This is the Tier 1+2 portion of +/// the credential resolution chain (Tier 3 is `read_env_or_config`). +pub(crate) fn read_env_non_empty(key: &str) -> Result, ApiError> { + match std::env::var(key) { + Ok(value) if !value.is_empty() => Ok(Some(value)), + Ok(_) | Err(std::env::VarError::NotPresent) => Ok(dotenv_value(key)), + Err(error) => Err(ApiError::from(error)), + } +} + +/// Resolve a provider's base URL using the 3-tier resolution chain: +/// 1. Process environment variable (e.g. `ANTHROPIC_BASE_URL`) +/// 2. `.env` file in the current working directory +/// 3. Stored provider config in `~/.claw/settings.json` +/// Falls back to `default` when no tier provides a non-empty value. +#[must_use] +pub fn resolve_base_url(env_var: &str, provider_kind: &str, default: &str) -> String { + // Tier 1: environment variable + if let Ok(value) = std::env::var(env_var) { + if !value.is_empty() { + return value; + } + } + // Tier 2: .env file + if let Some(value) = dotenv_value(env_var) { + if !value.is_empty() { + return value; + } + } + // Tier 3: stored config in ~/.claw/settings.json + if let Some(base_url) = read_base_url_from_config(provider_kind) { + return base_url; + } + default.to_string() +} + /// Look up `key` in a `.env` file located in the current working directory. /// Returns `None` when the file is missing, the key is absent, or the value /// is empty. diff --git a/rust/crates/api/src/providers/openai_compat.rs b/rust/crates/api/src/providers/openai_compat.rs index cb6b329ec2..6d7b9a58cb 100644 --- a/rust/crates/api/src/providers/openai_compat.rs +++ b/rust/crates/api/src/providers/openai_compat.rs @@ -150,6 +150,35 @@ impl OpenAiCompatClient { Ok(Self::new(api_key, config).with_base_url(base_url)) } + /// Resolve credentials using the 3-tier resolution chain: + /// 1. Environment variable (e.g. `OPENAI_API_KEY`) + /// 2. `.env` file in the current working directory + /// 3. Stored provider config in `~/.claw/settings.json` + /// + /// Falls back to the stored config when the environment variable is + /// not set. The stored provider kind in settings.json must match + /// this provider's kind (e.g. "openai", "xai", "dashscope"). + pub fn from_env_or_saved(config: OpenAiCompatConfig) -> Result { + // Tier 1+2: env vars (includes .env fallback via read_env_non_empty) + if let Some(api_key) = read_env_non_empty(config.api_key_env)? { + return Ok(Self::new(api_key, config)); + } + // Tier 3: stored config in ~/.claw/settings.json + let provider_kind = match config.provider_name { + "xAI" => "xai", + "DashScope" => "dashscope", + // "OpenAI" and custom providers + _ => "openai", + }; + if let Some(api_key) = super::read_env_or_config(config.api_key_env, provider_kind) { + return Ok(Self::new(api_key, config)); + } + Err(ApiError::missing_credentials( + config.provider_name, + config.credential_env_vars(), + )) + } + #[must_use] pub fn with_base_url(mut self, base_url: impl Into) -> Self { self.base_url = base_url.into(); @@ -1635,11 +1664,7 @@ fn parse_sse_frame( } fn read_env_non_empty(key: &str) -> Result, ApiError> { - match std::env::var(key) { - Ok(value) if !value.is_empty() => Ok(Some(value)), - Ok(_) | Err(std::env::VarError::NotPresent) => Ok(super::dotenv_value(key)), - Err(error) => Err(ApiError::from(error)), - } + super::read_env_non_empty(key) } #[must_use] @@ -1652,7 +1677,12 @@ pub fn has_api_key(key: &str) -> bool { #[must_use] pub fn read_base_url(config: OpenAiCompatConfig) -> String { - std::env::var(config.base_url_env).unwrap_or_else(|_| config.default_base_url.to_string()) + let provider_kind = match config.provider_name { + "xAI" => "xai", + "DashScope" => "dashscope", + _ => "openai", + }; + super::resolve_base_url(config.base_url_env, provider_kind, config.default_base_url) } fn chat_completions_endpoint(base_url: &str) -> String { diff --git a/rust/crates/runtime/src/config.rs b/rust/crates/runtime/src/config.rs index 806c3ed528..11eb44269a 100644 --- a/rust/crates/runtime/src/config.rs +++ b/rust/crates/runtime/src/config.rs @@ -139,10 +139,63 @@ pub struct RuntimeFeatureConfig { sandbox: SandboxConfig, provider_fallbacks: ProviderFallbackConfig, trusted_roots: Vec, + provider: RuntimeProviderConfig, rules_import: RulesImportConfig, } -/// Controls which external AI coding framework rules are imported into the system prompt. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct RuntimeProviderConfig { + kind: Option, + api_key: Option, + base_url: Option, +} + +impl RuntimeProviderConfig { + /// Construct from the top-level `"provider"` object in the merged + /// settings. The object uses the crate-internal `JsonValue` type, + /// not `serde_json::Value`. + fn from_provider_object(obj: &BTreeMap) -> Self { + Self { + kind: obj + .get("kind") + .and_then(JsonValue::as_str) + .map(String::from), + api_key: obj + .get("apiKey") + .and_then(JsonValue::as_str) + .map(String::from), + base_url: obj + .get("baseUrl") + .and_then(JsonValue::as_str) + .map(String::from), + } + } + + /// The provider kind string (e.g. "anthropic", "xai", "openai", "dashscope"). + #[must_use] + pub fn kind(&self) -> Option<&str> { + self.kind.as_deref() + } + + /// The stored API key for this provider. + #[must_use] + pub fn api_key(&self) -> Option<&str> { + self.api_key.as_deref() + } + + /// The optional custom base URL override. + #[must_use] + pub fn base_url(&self) -> Option<&str> { + self.base_url.as_deref() + } + + /// Whether any provider field is set. + #[must_use] + pub fn is_set(&self) -> bool { + self.kind.is_some() || self.api_key.is_some() + } +} + #[derive(Debug, Clone, PartialEq, Eq, Default)] pub enum RulesImportConfig { /// Import from all supported frameworks when files are detected. @@ -729,6 +782,7 @@ fn build_runtime_config( sandbox: parse_optional_sandbox_config(&merged_value)?, provider_fallbacks: parse_optional_provider_fallbacks(&merged_value)?, trusted_roots: parse_optional_trusted_roots(&merged_value)?, + provider: parse_optional_provider_config(&merged_value), rules_import: parse_optional_rules_import(&merged_value)?, }; @@ -834,6 +888,14 @@ impl RuntimeConfig { &self.feature_config.provider_fallbacks } + /// Return the stored provider configuration from `settings.json`. + /// Used by the 3-tier credential resolution path + /// (env var → .env → stored config). + #[must_use] + pub fn provider(&self) -> &RuntimeProviderConfig { + &self.feature_config.provider + } + #[must_use] pub fn trusted_roots(&self) -> &[String] { &self.feature_config.trusted_roots @@ -919,6 +981,12 @@ impl RuntimeFeatureConfig { &self.provider_fallbacks } + /// Return the stored provider configuration from `settings.json`. + #[must_use] + pub fn provider(&self) -> &RuntimeProviderConfig { + &self.provider + } + #[must_use] pub fn trusted_roots(&self) -> &[String] { &self.trusted_roots @@ -1612,6 +1680,21 @@ fn parse_optional_model(root: &JsonValue) -> Option { .map(ToOwned::to_owned) } +/// Parse the `"provider"` section from merged settings into a +/// [`RuntimeProviderConfig`]. Returns a default (empty) config when the +/// key is absent — this is the normal case when the user has not run +/// the setup wizard. +fn parse_optional_provider_config(root: &JsonValue) -> RuntimeProviderConfig { + let Some(obj) = root + .as_object() + .and_then(|object| object.get("provider")) + .and_then(JsonValue::as_object) + else { + return RuntimeProviderConfig::default(); + }; + RuntimeProviderConfig::from_provider_object(obj) +} + fn parse_optional_aliases(root: &JsonValue) -> Result, ConfigError> { let Some(object) = root.as_object() else { return Ok(BTreeMap::new()); @@ -2628,6 +2711,89 @@ mod tests { fs::remove_dir_all(root).expect("cleanup temp dir"); } + #[test] + fn provider_config_default_is_empty_when_unset() { + // given + let root = temp_dir(); + let cwd = root.join("project"); + let home = root.join("home").join(".claw"); + fs::create_dir_all(&home).expect("home config dir"); + fs::create_dir_all(&cwd).expect("project dir"); + fs::write(home.join("settings.json"), "{}").expect("write empty settings"); + + // when + let loaded = ConfigLoader::new(&cwd, &home) + .load() + .expect("config should load"); + + // then + let provider = loaded.provider(); + assert_eq!(provider.kind(), None); + assert_eq!(provider.api_key(), None); + assert_eq!(provider.base_url(), None); + assert!(!provider.is_set()); + + fs::remove_dir_all(root).expect("cleanup temp dir"); + } + + #[test] + fn provider_config_parses_kind_api_key_and_base_url() { + let root = temp_dir(); + let cwd = root.join("project"); + let home = root.join("home").join(".claw"); + fs::create_dir_all(&home).expect("home config dir"); + fs::create_dir_all(&cwd).expect("project dir"); + fs::write( + home.join("settings.json"), + r#"{"provider": {"kind": "anthropic", "apiKey": "sk-ant-test-key", "baseUrl": "https://custom.api.anthropic.com"}, "model": "sonnet"}"#, + ) + .expect("write settings"); + + let loaded = ConfigLoader::new(&cwd, &home) + .load() + .expect("config should load"); + + let provider = loaded.provider(); + assert_eq!(provider.kind(), Some("anthropic")); + assert_eq!(provider.api_key(), Some("sk-ant-test-key")); + assert_eq!( + provider.base_url(), + Some("https://custom.api.anthropic.com") + ); + assert!(provider.is_set()); + + // model is a separate top-level field + assert_eq!(loaded.model(), Some("sonnet")); + + fs::remove_dir_all(root).expect("cleanup temp dir"); + } + + #[test] + fn provider_config_handles_partial_provider_object() { + // given — only kind and apiKey set, no baseUrl + let root = temp_dir(); + let cwd = root.join("project"); + let home = root.join("home").join(".claw"); + fs::create_dir_all(&home).expect("home config dir"); + fs::create_dir_all(&cwd).expect("project dir"); + fs::write( + home.join("settings.json"), + r#"{"provider": {"kind": "openai", "apiKey": "sk-openai-test"}}"#, + ) + .expect("write settings"); + + let loaded = ConfigLoader::new(&cwd, &home) + .load() + .expect("config should load"); + + let provider = loaded.provider(); + assert_eq!(provider.kind(), Some("openai")); + assert_eq!(provider.api_key(), Some("sk-openai-test")); + assert_eq!(provider.base_url(), None); + + fs::remove_dir_all(root).expect("cleanup temp dir"); + } + #[test] fn parses_rules_import_config() { let root = temp_dir(); diff --git a/rust/crates/runtime/src/lib.rs b/rust/crates/runtime/src/lib.rs index e11b91d8c6..99b8992537 100644 --- a/rust/crates/runtime/src/lib.rs +++ b/rust/crates/runtime/src/lib.rs @@ -71,8 +71,8 @@ pub use config::{ McpSdkServerConfig, McpServerConfig, McpStdioServerConfig, McpTransport, McpWebSocketServerConfig, OAuthConfig, ProviderFallbackConfig, ResolvedPermissionMode, RulesImportConfig, RuntimeConfig, RuntimeFeatureConfig, RuntimeHookCommand, RuntimeHookConfig, - RuntimePermissionRuleConfig, RuntimePluginConfig, ScopedMcpServerConfig, - CLAW_SETTINGS_SCHEMA_NAME, + RuntimePermissionRuleConfig, RuntimePluginConfig, RuntimeProviderConfig, + ScopedMcpServerConfig, CLAW_SETTINGS_SCHEMA_NAME, }; pub use config_validate::{ check_unsupported_format, format_diagnostics, validate_config_file, ConfigDiagnostic,