Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 36 additions & 8 deletions rust/crates/api/src/providers/anthropic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Self, ApiError> {
// 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 {
Expand All @@ -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())
}
}
Expand All @@ -651,14 +675,15 @@ pub fn resolve_saved_oauth_token(config: &OAuthConfig) -> Result<Option<OAuthTok

pub fn has_auth_from_env_or_saved() -> Result<bool, ApiError> {
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<F>(load_oauth_config: F) -> Result<AuthSource, ApiError>
where
F: FnOnce() -> Result<Option<OAuthConfig>, 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 {
Expand All @@ -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())
}

Expand Down Expand Up @@ -739,11 +771,7 @@ fn now_unix_timestamp() -> u64 {
}

fn read_env_non_empty(key: &str) -> Result<Option<String>, 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)]
Expand All @@ -764,7 +792,7 @@ fn read_auth_token() -> Option<String> {

#[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<String> {
Expand Down
98 changes: 98 additions & 0 deletions rust/crates/api/src/providers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -686,6 +686,66 @@ fn estimate_serialized_tokens<T: Serialize>(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<String> {
// 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<String> {
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<String> {
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
Expand Down Expand Up @@ -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<Option<String>, 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.
Expand Down
42 changes: 36 additions & 6 deletions rust/crates/api/src/providers/openai_compat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Self, ApiError> {
// 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<String>) -> Self {
self.base_url = base_url.into();
Expand Down Expand Up @@ -1577,11 +1606,7 @@ fn parse_sse_frame(
}

fn read_env_non_empty(key: &str) -> Result<Option<String>, 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]
Expand All @@ -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 {
Expand Down
Loading