mirror of
https://github.com/instructkr/claw-code.git
synced 2026-04-08 00:54:49 +08:00
fix(api): Windows env hint + .env file loading fallback
When API key missing on Windows, hint about setx. Load .env from CWD as fallback with simple key=value parser.
This commit is contained in:
@@ -204,11 +204,27 @@ impl ApiError {
|
|||||||
impl Display for ApiError {
|
impl Display for ApiError {
|
||||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||||
match self {
|
match self {
|
||||||
Self::MissingCredentials { provider, env_vars } => write!(
|
Self::MissingCredentials { provider, env_vars } => {
|
||||||
f,
|
write!(
|
||||||
"missing {provider} credentials; export {} before calling the {provider} API",
|
f,
|
||||||
env_vars.join(" or ")
|
"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} <value>` to make it permanent, then open a new terminal, or place a `.env` file containing `{primary}=<value>` 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 {
|
Self::ContextWindowExceeded {
|
||||||
model,
|
model,
|
||||||
estimated_input_tokens,
|
estimated_input_tokens,
|
||||||
|
|||||||
@@ -732,7 +732,7 @@ fn now_unix_timestamp() -> u64 {
|
|||||||
fn read_env_non_empty(key: &str) -> Result<Option<String>, ApiError> {
|
fn read_env_non_empty(key: &str) -> Result<Option<String>, ApiError> {
|
||||||
match std::env::var(key) {
|
match std::env::var(key) {
|
||||||
Ok(value) if !value.is_empty() => Ok(Some(value)),
|
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)),
|
Err(error) => Err(ApiError::from(error)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -258,6 +258,61 @@ fn estimate_serialized_tokens<T: Serialize>(value: &T) -> u32 {
|
|||||||
.map_or(0, |bytes| (bytes.len() / 4 + 1) as 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<String, String> {
|
||||||
|
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<std::collections::HashMap<String, String>> {
|
||||||
|
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<String> {
|
||||||
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
@@ -268,8 +323,8 @@ mod tests {
|
|||||||
};
|
};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
detect_provider_kind, max_tokens_for_model, model_token_limit, preflight_message_request,
|
detect_provider_kind, load_dotenv_file, max_tokens_for_model, model_token_limit,
|
||||||
resolve_model_alias, ProviderKind,
|
parse_dotenv, preflight_message_request, resolve_model_alias, ProviderKind,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -375,4 +430,85 @@ mod tests {
|
|||||||
preflight_message_request(&request)
|
preflight_message_request(&request)
|
||||||
.expect("models without context metadata should skip the guarded preflight");
|
.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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -886,7 +886,7 @@ fn parse_sse_frame(
|
|||||||
fn read_env_non_empty(key: &str) -> Result<Option<String>, ApiError> {
|
fn read_env_non_empty(key: &str) -> Result<Option<String>, ApiError> {
|
||||||
match std::env::var(key) {
|
match std::env::var(key) {
|
||||||
Ok(value) if !value.is_empty() => Ok(Some(value)),
|
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)),
|
Err(error) => Err(ApiError::from(error)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user