diff --git a/rust/crates/api/src/providers/anthropic.rs b/rust/crates/api/src/providers/anthropic.rs index 51e10b44dd..85f7b57035 100644 --- a/rust/crates/api/src/providers/anthropic.rs +++ b/rust/crates/api/src/providers/anthropic.rs @@ -618,7 +618,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 { @@ -631,6 +640,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()) } } @@ -651,14 +675,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 { @@ -671,6 +696,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()) } @@ -739,11 +771,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)] @@ -764,7 +792,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 237e979969..9c5ad04877 100644 --- a/rust/crates/api/src/providers/mod.rs +++ b/rust/crates/api/src/providers/mod.rs @@ -686,6 +686,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 @@ -799,6 +859,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 d5291b8eae..9a643c8612 100644 --- a/rust/crates/api/src/providers/openai_compat.rs +++ b/rust/crates/api/src/providers/openai_compat.rs @@ -140,6 +140,35 @@ impl OpenAiCompatClient { Ok(Self::new(api_key, config)) } + /// 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(); @@ -1577,11 +1606,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] @@ -1594,7 +1619,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 0379e31b56..76f4c9c877 100644 --- a/rust/crates/runtime/src/config.rs +++ b/rust/crates/runtime/src/config.rs @@ -95,6 +95,76 @@ pub struct RuntimeFeatureConfig { sandbox: SandboxConfig, provider_fallbacks: ProviderFallbackConfig, trusted_roots: Vec, + provider: RuntimeProviderConfig, +} + +/// Stored provider configuration read from `~/.claw/settings.json` +/// under the top-level `"provider"` key. Written by the setup wizard +/// and read back by the 3-tier credential resolution path +/// (env var → .env → stored config). +/// +/// JSON shape in settings.json: +/// ```json +/// { +/// "provider": { +/// "kind": "anthropic", +/// "apiKey": "sk-ant-...", +/// "baseUrl": "https://api.anthropic.com" +/// }, +/// "model": "sonnet" +/// } +/// ``` +#[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() + } } /// Ordered chain of fallback model identifiers used when the primary @@ -353,6 +423,7 @@ impl ConfigLoader { 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), }; Ok(RuntimeConfig { @@ -410,6 +481,7 @@ impl ConfigLoader { 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), }; let config = RuntimeConfig { @@ -506,6 +578,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 @@ -586,6 +666,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 @@ -982,6 +1068,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()); @@ -1724,6 +1825,94 @@ 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() { + // 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"), + r#"{"provider": {"kind": "anthropic", "apiKey": "sk-ant-test-key", "baseUrl": "https://custom.api.anthropic.com"}, "model": "sonnet"}"#, + ) + .expect("write settings"); + + // when + let loaded = ConfigLoader::new(&cwd, &home) + .load() + .expect("config should load"); + + // then + 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"); + + // when + let loaded = ConfigLoader::new(&cwd, &home) + .load() + .expect("config should load"); + + // then + 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_trusted_roots_from_settings() { // given diff --git a/rust/crates/runtime/src/lib.rs b/rust/crates/runtime/src/lib.rs index f0ab67c30e..768c1bc45d 100644 --- a/rust/crates/runtime/src/lib.rs +++ b/rust/crates/runtime/src/lib.rs @@ -70,7 +70,7 @@ pub use config::{ McpSdkServerConfig, McpServerConfig, McpStdioServerConfig, McpTransport, McpWebSocketServerConfig, OAuthConfig, ProviderFallbackConfig, ResolvedPermissionMode, RuntimeConfig, RuntimeFeatureConfig, RuntimeHookConfig, RuntimePermissionRuleConfig, - RuntimePluginConfig, ScopedMcpServerConfig, CLAW_SETTINGS_SCHEMA_NAME, + RuntimePluginConfig, RuntimeProviderConfig, ScopedMcpServerConfig, CLAW_SETTINGS_SCHEMA_NAME, }; pub use config_validate::{ check_unsupported_format, format_diagnostics, validate_config_file, ConfigDiagnostic,