diff --git a/rust/crates/api/src/error.rs b/rust/crates/api/src/error.rs index e50d1ea..2aa84d9 100644 --- a/rust/crates/api/src/error.rs +++ b/rust/crates/api/src/error.rs @@ -204,11 +204,27 @@ impl ApiError { impl Display for ApiError { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self { - Self::MissingCredentials { provider, env_vars } => write!( - f, - "missing {provider} credentials; export {} before calling the {provider} API", - env_vars.join(" or ") - ), + Self::MissingCredentials { provider, env_vars } => { + write!( + f, + "missing {provider} credentials; export {} before calling the {provider} API", + env_vars.join(" or ") + )?; + if cfg!(target_os = "windows") { + if let Some(primary) = env_vars.first() { + write!( + f, + " (on Windows, environment variables set in PowerShell only persist for the current session; use `setx {primary} ` to make it permanent, then open a new terminal, or place a `.env` file containing `{primary}=` in the current working directory)" + )?; + } else { + write!( + f, + " (on Windows, environment variables set in PowerShell only persist for the current session; use `setx` to make them permanent, then open a new terminal, or place a `.env` file in the current working directory)" + )?; + } + } + Ok(()) + } Self::ContextWindowExceeded { model, estimated_input_tokens, diff --git a/rust/crates/api/src/providers/anthropic.rs b/rust/crates/api/src/providers/anthropic.rs index 8b1ccae..ba02b83 100644 --- a/rust/crates/api/src/providers/anthropic.rs +++ b/rust/crates/api/src/providers/anthropic.rs @@ -732,7 +732,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(None), + Ok(_) | Err(std::env::VarError::NotPresent) => Ok(super::dotenv_value(key)), Err(error) => Err(ApiError::from(error)), } } diff --git a/rust/crates/api/src/providers/mod.rs b/rust/crates/api/src/providers/mod.rs index 06eb77e..c5ed567 100644 --- a/rust/crates/api/src/providers/mod.rs +++ b/rust/crates/api/src/providers/mod.rs @@ -258,6 +258,61 @@ fn estimate_serialized_tokens(value: &T) -> u32 { .map_or(0, |bytes| (bytes.len() / 4 + 1) as u32) } +/// Parse a `.env` file body into key/value pairs using a minimal `KEY=VALUE` +/// grammar. Lines that are blank, start with `#`, or do not contain `=` are +/// ignored. Surrounding double or single quotes are stripped from the value. +/// An optional leading `export ` prefix on the key is also stripped so files +/// shared with shell `source` workflows still parse cleanly. +pub(crate) fn parse_dotenv(content: &str) -> std::collections::HashMap { + let mut values = std::collections::HashMap::new(); + for raw_line in content.lines() { + let line = raw_line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let Some((raw_key, raw_value)) = line.split_once('=') else { + continue; + }; + let trimmed_key = raw_key.trim(); + let key = trimmed_key + .strip_prefix("export ") + .map_or(trimmed_key, str::trim) + .to_string(); + if key.is_empty() { + continue; + } + let trimmed_value = raw_value.trim(); + let unquoted = if (trimmed_value.starts_with('"') && trimmed_value.ends_with('"') + || trimmed_value.starts_with('\'') && trimmed_value.ends_with('\'')) + && trimmed_value.len() >= 2 + { + &trimmed_value[1..trimmed_value.len() - 1] + } else { + trimmed_value + }; + values.insert(key, unquoted.to_string()); + } + values +} + +/// Load and parse a `.env` file from the given path. Missing files yield +/// `None` instead of an error so callers can use this as a soft fallback. +pub(crate) fn load_dotenv_file( + path: &std::path::Path, +) -> Option> { + let content = std::fs::read_to_string(path).ok()?; + Some(parse_dotenv(&content)) +} + +/// 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. +pub(crate) fn dotenv_value(key: &str) -> Option { + let cwd = std::env::current_dir().ok()?; + let values = load_dotenv_file(&cwd.join(".env"))?; + values.get(key).filter(|value| !value.is_empty()).cloned() +} + #[cfg(test)] mod tests { use serde_json::json; @@ -268,8 +323,8 @@ mod tests { }; use super::{ - detect_provider_kind, max_tokens_for_model, model_token_limit, preflight_message_request, - resolve_model_alias, ProviderKind, + detect_provider_kind, load_dotenv_file, max_tokens_for_model, model_token_limit, + parse_dotenv, preflight_message_request, resolve_model_alias, ProviderKind, }; #[test] @@ -375,4 +430,85 @@ mod tests { preflight_message_request(&request) .expect("models without context metadata should skip the guarded preflight"); } + + #[test] + fn parse_dotenv_extracts_keys_handles_comments_quotes_and_export_prefix() { + // given + let body = "\ +# this is a comment + +ANTHROPIC_API_KEY=plain-value +XAI_API_KEY=\"quoted-value\" +OPENAI_API_KEY='single-quoted' +export GROK_API_KEY=exported-value + PADDED_KEY = padded-value +EMPTY_VALUE= +NO_EQUALS_LINE +"; + + // when + let values = parse_dotenv(body); + + // then + assert_eq!( + values.get("ANTHROPIC_API_KEY").map(String::as_str), + Some("plain-value") + ); + assert_eq!( + values.get("XAI_API_KEY").map(String::as_str), + Some("quoted-value") + ); + assert_eq!( + values.get("OPENAI_API_KEY").map(String::as_str), + Some("single-quoted") + ); + assert_eq!( + values.get("GROK_API_KEY").map(String::as_str), + Some("exported-value") + ); + assert_eq!( + values.get("PADDED_KEY").map(String::as_str), + Some("padded-value") + ); + assert_eq!(values.get("EMPTY_VALUE").map(String::as_str), Some("")); + assert!(!values.contains_key("NO_EQUALS_LINE")); + assert!(!values.contains_key("# this is a comment")); + } + + #[test] + fn load_dotenv_file_reads_keys_from_disk_and_returns_none_when_missing() { + // given + let temp_root = std::env::temp_dir().join(format!( + "api-dotenv-test-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |duration| duration.as_nanos()) + )); + std::fs::create_dir_all(&temp_root).expect("create temp dir"); + let env_path = temp_root.join(".env"); + std::fs::write( + &env_path, + "ANTHROPIC_API_KEY=secret-from-file\n# comment\nXAI_API_KEY=\"xai-secret\"\n", + ) + .expect("write .env"); + let missing_path = temp_root.join("does-not-exist.env"); + + // when + let loaded = load_dotenv_file(&env_path).expect("file should load"); + let missing = load_dotenv_file(&missing_path); + + // then + assert_eq!( + loaded.get("ANTHROPIC_API_KEY").map(String::as_str), + Some("secret-from-file") + ); + assert_eq!( + loaded.get("XAI_API_KEY").map(String::as_str), + Some("xai-secret") + ); + assert!(missing.is_none()); + + let _ = std::fs::remove_dir_all(&temp_root); + } } diff --git a/rust/crates/api/src/providers/openai_compat.rs b/rust/crates/api/src/providers/openai_compat.rs index 6467247..1c1ee27 100644 --- a/rust/crates/api/src/providers/openai_compat.rs +++ b/rust/crates/api/src/providers/openai_compat.rs @@ -886,7 +886,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(None), + Ok(_) | Err(std::env::VarError::NotPresent) => Ok(super::dotenv_value(key)), Err(error) => Err(ApiError::from(error)), } }