diff --git a/USAGE.md b/USAGE.md index 89e5a10..d68c0b5 100644 --- a/USAGE.md +++ b/USAGE.md @@ -109,6 +109,20 @@ cd rust ./target/debug/claw logout ``` +### Which env var goes where + +`claw` accepts two Anthropic credential env vars and they are **not interchangeable** — the HTTP header Anthropic expects differs per credential shape. Putting the wrong value in the wrong slot is the most common 401 we see. + +| Credential shape | Env var | HTTP header | Typical source | +|---|---|---|---| +| `sk-ant-*` API key | `ANTHROPIC_API_KEY` | `x-api-key: sk-ant-...` | [console.anthropic.com](https://console.anthropic.com) | +| OAuth access token (opaque) | `ANTHROPIC_AUTH_TOKEN` | `Authorization: Bearer ...` | `claw login` or an Anthropic-compatible proxy that mints Bearer tokens | +| OpenRouter key (`sk-or-v1-*`) | `OPENAI_API_KEY` + `OPENAI_BASE_URL=https://openrouter.ai/api/v1` | `Authorization: Bearer ...` | [openrouter.ai/keys](https://openrouter.ai/keys) | + +**Why this matters:** if you paste an `sk-ant-*` key into `ANTHROPIC_AUTH_TOKEN`, Anthropic's API will return `401 Invalid bearer token` because `sk-ant-*` keys are rejected over the Bearer header. The fix is a one-line env var swap — move the key to `ANTHROPIC_API_KEY`. Recent `claw` builds detect this exact shape (401 + `sk-ant-*` in the Bearer slot) and append a hint to the error message pointing at the fix. + +**If you meant a different provider:** if `claw` reports missing Anthropic credentials but you already have `OPENAI_API_KEY`, `XAI_API_KEY`, or `DASHSCOPE_API_KEY` exported, you most likely forgot to prefix the model name with the provider's routing prefix. Use `--model openai/gpt-4.1-mini` (OpenAI-compat / OpenRouter / Ollama), `--model grok` (xAI), or `--model qwen-plus` (DashScope) and the prefix router will select the right backend regardless of the ambient credentials. The error message now includes a hint that names the detected env var. + ## Local Models `claw` can talk to local servers and provider gateways through either Anthropic-compatible or OpenAI-compatible endpoints. Use `ANTHROPIC_BASE_URL` with `ANTHROPIC_AUTH_TOKEN` for Anthropic-compatible services, or `OPENAI_BASE_URL` with `OPENAI_API_KEY` for OpenAI-compatible services. OAuth is Anthropic-only, so when `OPENAI_BASE_URL` is set you should use API-key style auth instead of `claw login`. diff --git a/rust/crates/api/src/error.rs b/rust/crates/api/src/error.rs index 2aa84d9..3fa4995 100644 --- a/rust/crates/api/src/error.rs +++ b/rust/crates/api/src/error.rs @@ -22,6 +22,11 @@ pub enum ApiError { MissingCredentials { provider: &'static str, env_vars: &'static [&'static str], + /// Optional, runtime-computed hint appended to the error Display + /// output. Populated when the provider resolver can infer what the + /// user probably intended (e.g. an OpenAI key is set but Anthropic + /// was selected because no Anthropic credentials exist). + hint: Option, }, ContextWindowExceeded { model: String, @@ -66,7 +71,29 @@ impl ApiError { provider: &'static str, env_vars: &'static [&'static str], ) -> Self { - Self::MissingCredentials { provider, env_vars } + Self::MissingCredentials { + provider, + env_vars, + hint: None, + } + } + + /// Build a `MissingCredentials` error carrying an extra, runtime-computed + /// hint string that the Display impl appends after the canonical "missing + /// credentials" message. Used by the provider resolver to + /// suggest the likely fix when the user has credentials for a different + /// provider already in the environment. + #[must_use] + pub fn missing_credentials_with_hint( + provider: &'static str, + env_vars: &'static [&'static str], + hint: impl Into, + ) -> Self { + Self::MissingCredentials { + provider, + env_vars, + hint: Some(hint.into()), + } } /// Build a `Self::Json` enriched with the provider name, the model that @@ -204,7 +231,11 @@ impl ApiError { impl Display for ApiError { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self { - Self::MissingCredentials { provider, env_vars } => { + Self::MissingCredentials { + provider, + env_vars, + hint, + } => { write!( f, "missing {provider} credentials; export {} before calling the {provider} API", @@ -223,6 +254,9 @@ impl Display for ApiError { )?; } } + if let Some(hint) = hint { + write!(f, " — hint: {hint}")?; + } Ok(()) } Self::ContextWindowExceeded { @@ -483,4 +517,56 @@ mod tests { assert_eq!(error.safe_failure_class(), "context_window"); assert_eq!(error.request_id(), Some("req_ctx_123")); } + + #[test] + fn missing_credentials_without_hint_renders_the_canonical_message() { + // given + let error = ApiError::missing_credentials( + "Anthropic", + &["ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"], + ); + + // when + let rendered = error.to_string(); + + // then + assert!( + rendered.starts_with( + "missing Anthropic credentials; export ANTHROPIC_AUTH_TOKEN or ANTHROPIC_API_KEY before calling the Anthropic API" + ), + "rendered error should lead with the canonical missing-credential message: {rendered}" + ); + assert!( + !rendered.contains(" — hint: "), + "no hint should be appended when none is supplied: {rendered}" + ); + } + + #[test] + fn missing_credentials_with_hint_appends_the_hint_after_base_message() { + // given + let error = ApiError::missing_credentials_with_hint( + "Anthropic", + &["ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"], + "I see OPENAI_API_KEY is set — if you meant to use the OpenAI-compat provider, prefix your model name with `openai/` so prefix routing selects it.", + ); + + // when + let rendered = error.to_string(); + + // then + assert!( + rendered.starts_with("missing Anthropic credentials;"), + "hint should be appended, not replace the base message: {rendered}" + ); + let hint_marker = " — hint: I see OPENAI_API_KEY is set — if you meant to use the OpenAI-compat provider, prefix your model name with `openai/` so prefix routing selects it."; + assert!( + rendered.ends_with(hint_marker), + "rendered error should end with the hint: {rendered}" + ); + // Classification semantics are unaffected by the presence of a hint. + assert_eq!(error.safe_failure_class(), "provider_auth"); + assert!(!error.is_retryable()); + assert_eq!(error.request_id(), None); + } } diff --git a/rust/crates/api/src/providers/anthropic.rs b/rust/crates/api/src/providers/anthropic.rs index 254982c..6e62b7d 100644 --- a/rust/crates/api/src/providers/anthropic.rs +++ b/rust/crates/api/src/providers/anthropic.rs @@ -16,7 +16,9 @@ use crate::error::ApiError; use crate::http_client::build_http_client_or_default; use crate::prompt_cache::{PromptCache, PromptCacheRecord, PromptCacheStats}; -use super::{model_token_limit, resolve_model_alias, Provider, ProviderFuture}; +use super::{ + anthropic_missing_credentials, model_token_limit, resolve_model_alias, Provider, ProviderFuture, +}; use crate::sse::SseParser; use crate::types::{MessageDeltaEvent, MessageRequest, MessageResponse, StreamEvent, Usage}; @@ -49,10 +51,7 @@ impl AuthSource { }), (Some(api_key), None) => Ok(Self::ApiKey(api_key)), (None, Some(bearer_token)) => Ok(Self::BearerToken(bearer_token)), - (None, None) => Err(ApiError::missing_credentials( - "Anthropic", - &["ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"], - )), + (None, None) => Err(anthropic_missing_credentials()), } } @@ -436,6 +435,7 @@ impl AnthropicClient { last_error = Some(error); } Err(error) => { + let error = enrich_bearer_auth_error(error, &self.auth); self.record_request_failure(attempts, &error); return Err(error); } @@ -643,10 +643,7 @@ impl AuthSource { } } Ok(Some(token_set)) => Ok(Self::BearerToken(token_set.access_token)), - Ok(None) => Err(ApiError::missing_credentials( - "Anthropic", - &["ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"], - )), + Ok(None) => Err(anthropic_missing_credentials()), Err(error) => Err(error), } } @@ -690,10 +687,7 @@ where } let Some(token_set) = load_saved_oauth_token()? else { - return Err(ApiError::missing_credentials( - "Anthropic", - &["ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"], - )); + return Err(anthropic_missing_credentials()); }; if !oauth_token_is_expired(&token_set) { return Ok(AuthSource::BearerToken(token_set.access_token)); @@ -790,10 +784,7 @@ fn read_api_key() -> Result { auth.api_key() .or_else(|| auth.bearer_token()) .map(ToOwned::to_owned) - .ok_or(ApiError::missing_credentials( - "Anthropic", - &["ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"], - )) + .ok_or_else(anthropic_missing_credentials) } #[cfg(test)] @@ -934,6 +925,85 @@ const fn is_retryable_status(status: reqwest::StatusCode) -> bool { matches!(status.as_u16(), 408 | 409 | 429 | 500 | 502 | 503 | 504) } +/// Anthropic API keys (`sk-ant-*`) are accepted over the `x-api-key` header +/// and rejected with HTTP 401 "Invalid bearer token" when sent as a Bearer +/// token via `ANTHROPIC_AUTH_TOKEN`. This happens often enough in the wild +/// (users copy-paste an `sk-ant-...` key into `ANTHROPIC_AUTH_TOKEN` because +/// the env var name sounds auth-related) that a bare 401 error is useless. +/// When we detect this exact shape, append a hint to the error message that +/// points the user at the one-line fix. +const SK_ANT_BEARER_HINT: &str = "sk-ant-* keys go in ANTHROPIC_API_KEY (x-api-key header), not ANTHROPIC_AUTH_TOKEN (Bearer header). Move your key to ANTHROPIC_API_KEY."; + +fn enrich_bearer_auth_error(error: ApiError, auth: &AuthSource) -> ApiError { + let ApiError::Api { + status, + error_type, + message, + request_id, + body, + retryable, + } = error + else { + return error; + }; + if status.as_u16() != 401 { + return ApiError::Api { + status, + error_type, + message, + request_id, + body, + retryable, + }; + } + let Some(bearer_token) = auth.bearer_token() else { + return ApiError::Api { + status, + error_type, + message, + request_id, + body, + retryable, + }; + }; + if !bearer_token.starts_with("sk-ant-") { + return ApiError::Api { + status, + error_type, + message, + request_id, + body, + retryable, + }; + } + // Only append the hint when the AuthSource is pure BearerToken. If both + // api_key and bearer_token are present (`ApiKeyAndBearer`), the x-api-key + // header is already being sent alongside the Bearer header and the 401 + // is coming from a different cause — adding the hint would be misleading. + if auth.api_key().is_some() { + return ApiError::Api { + status, + error_type, + message, + request_id, + body, + retryable, + }; + } + let enriched_message = match message { + Some(existing) => Some(format!("{existing} — hint: {SK_ANT_BEARER_HINT}")), + None => Some(format!("hint: {SK_ANT_BEARER_HINT}")), + }; + ApiError::Api { + status, + error_type, + message: enriched_message, + request_id, + body, + retryable, + } +} + /// Remove beta-only body fields that the standard `/v1/messages` and /// `/v1/messages/count_tokens` endpoints reject as `Extra inputs are not /// permitted`. The `betas` opt-in is communicated via the `anthropic-beta` @@ -1538,4 +1608,163 @@ mod tests { Some("claude-sonnet-4-6") ); } + + #[test] + fn enrich_bearer_auth_error_appends_sk_ant_hint_on_401_with_pure_bearer_token() { + // given + let auth = AuthSource::BearerToken("sk-ant-api03-deadbeef".to_string()); + let error = crate::error::ApiError::Api { + status: reqwest::StatusCode::UNAUTHORIZED, + error_type: Some("authentication_error".to_string()), + message: Some("Invalid bearer token".to_string()), + request_id: Some("req_varleg_001".to_string()), + body: String::new(), + retryable: false, + }; + + // when + let enriched = super::enrich_bearer_auth_error(error, &auth); + + // then + let rendered = enriched.to_string(); + assert!( + rendered.contains("Invalid bearer token"), + "existing provider message should be preserved: {rendered}" + ); + assert!( + rendered.contains( + "sk-ant-* keys go in ANTHROPIC_API_KEY (x-api-key header), not ANTHROPIC_AUTH_TOKEN (Bearer header). Move your key to ANTHROPIC_API_KEY." + ), + "rendered error should include the sk-ant-* hint: {rendered}" + ); + assert!( + rendered.contains("[trace req_varleg_001]"), + "request id should still flow through the enriched error: {rendered}" + ); + match enriched { + crate::error::ApiError::Api { status, .. } => { + assert_eq!(status, reqwest::StatusCode::UNAUTHORIZED); + } + other => panic!("expected Api variant, got {other:?}"), + } + } + + #[test] + fn enrich_bearer_auth_error_leaves_non_401_errors_unchanged() { + // given + let auth = AuthSource::BearerToken("sk-ant-api03-deadbeef".to_string()); + let error = crate::error::ApiError::Api { + status: reqwest::StatusCode::INTERNAL_SERVER_ERROR, + error_type: Some("api_error".to_string()), + message: Some("internal server error".to_string()), + request_id: None, + body: String::new(), + retryable: true, + }; + + // when + let enriched = super::enrich_bearer_auth_error(error, &auth); + + // then + let rendered = enriched.to_string(); + assert!( + !rendered.contains("sk-ant-*"), + "non-401 errors must not be annotated with the bearer hint: {rendered}" + ); + assert!( + rendered.contains("internal server error"), + "original message must be preserved verbatim: {rendered}" + ); + } + + #[test] + fn enrich_bearer_auth_error_ignores_401_when_bearer_token_is_not_sk_ant() { + // given + let auth = AuthSource::BearerToken("oauth-access-token-opaque".to_string()); + let error = crate::error::ApiError::Api { + status: reqwest::StatusCode::UNAUTHORIZED, + error_type: Some("authentication_error".to_string()), + message: Some("Invalid bearer token".to_string()), + request_id: None, + body: String::new(), + retryable: false, + }; + + // when + let enriched = super::enrich_bearer_auth_error(error, &auth); + + // then + let rendered = enriched.to_string(); + assert!( + !rendered.contains("sk-ant-*"), + "oauth-style bearer tokens must not trigger the sk-ant-* hint: {rendered}" + ); + } + + #[test] + fn enrich_bearer_auth_error_skips_hint_when_api_key_header_is_also_present() { + // given + let auth = AuthSource::ApiKeyAndBearer { + api_key: "sk-ant-api03-legitimate".to_string(), + bearer_token: "sk-ant-api03-deadbeef".to_string(), + }; + let error = crate::error::ApiError::Api { + status: reqwest::StatusCode::UNAUTHORIZED, + error_type: Some("authentication_error".to_string()), + message: Some("Invalid bearer token".to_string()), + request_id: None, + body: String::new(), + retryable: false, + }; + + // when + let enriched = super::enrich_bearer_auth_error(error, &auth); + + // then + let rendered = enriched.to_string(); + assert!( + !rendered.contains("sk-ant-*"), + "hint should be suppressed when x-api-key header is already being sent: {rendered}" + ); + } + + #[test] + fn enrich_bearer_auth_error_ignores_401_when_auth_source_has_no_bearer() { + // given + let auth = AuthSource::ApiKey("sk-ant-api03-legitimate".to_string()); + let error = crate::error::ApiError::Api { + status: reqwest::StatusCode::UNAUTHORIZED, + error_type: Some("authentication_error".to_string()), + message: Some("Invalid x-api-key".to_string()), + request_id: None, + body: String::new(), + retryable: false, + }; + + // when + let enriched = super::enrich_bearer_auth_error(error, &auth); + + // then + let rendered = enriched.to_string(); + assert!( + !rendered.contains("sk-ant-*"), + "bearer hint must not apply when AuthSource is ApiKey-only: {rendered}" + ); + } + + #[test] + fn enrich_bearer_auth_error_passes_non_api_errors_through_unchanged() { + // given + let auth = AuthSource::BearerToken("sk-ant-api03-deadbeef".to_string()); + let error = crate::error::ApiError::InvalidSseFrame("unterminated event"); + + // when + let enriched = super::enrich_bearer_auth_error(error, &auth); + + // then + assert!(matches!( + enriched, + crate::error::ApiError::InvalidSseFrame(_) + )); + } } diff --git a/rust/crates/api/src/providers/mod.rs b/rust/crates/api/src/providers/mod.rs index 1703637..e725161 100644 --- a/rust/crates/api/src/providers/mod.rs +++ b/rust/crates/api/src/providers/mod.rs @@ -291,6 +291,73 @@ fn estimate_serialized_tokens(value: &T) -> u32 { .map_or(0, |bytes| (bytes.len() / 4 + 1) as u32) } +/// 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 +/// model-prefix routing fix that would select it. +const FOREIGN_PROVIDER_ENV_VARS: &[(&str, &str, &str)] = &[ + ( + "OPENAI_API_KEY", + "OpenAI-compat", + "prefix your model name with `openai/` (e.g. `--model openai/gpt-4.1-mini`) so prefix routing selects the OpenAI-compatible provider, and set `OPENAI_BASE_URL` if you are pointing at OpenRouter/Ollama/a local server", + ), + ( + "XAI_API_KEY", + "xAI", + "use an xAI model alias (e.g. `--model grok` or `--model grok-mini`) so the prefix router selects the xAI backend", + ), + ( + "DASHSCOPE_API_KEY", + "Alibaba DashScope", + "prefix your model name with `qwen/` or `qwen-` (e.g. `--model qwen-plus`) so prefix routing selects the DashScope backend", + ), +]; + +/// Check whether an env var is set to a non-empty value either in the real +/// process environment or in the working-directory `.env` file. Mirrors the +/// credential discovery path used by `read_env_non_empty` so the hint text +/// stays truthful when users rely on `.env` instead of a real export. +fn env_or_dotenv_present(key: &str) -> bool { + match std::env::var(key) { + Ok(value) if !value.is_empty() => true, + Ok(_) | Err(std::env::VarError::NotPresent) => { + dotenv_value(key).is_some_and(|value| !value.is_empty()) + } + Err(_) => false, + } +} + +/// Produce a hint string describing the first foreign provider credential +/// that is present in the environment when Anthropic auth resolution has +/// just failed. Returns `None` when no foreign credential is set, in which +/// case the caller should fall back to the plain `missing_credentials` +/// error without a hint. +pub(crate) fn anthropic_missing_credentials_hint() -> Option { + for (env_var, provider_label, fix_hint) in FOREIGN_PROVIDER_ENV_VARS { + if env_or_dotenv_present(env_var) { + return Some(format!( + "I see {env_var} is set — if you meant to use the {provider_label} provider, {fix_hint}." + )); + } + } + None +} + +/// Build an Anthropic-specific `MissingCredentials` error, attaching a +/// hint suggesting the probable fix whenever a different provider's +/// credentials are already present in the environment. Anthropic call +/// sites should prefer this helper over `ApiError::missing_credentials` +/// so users who mistyped a model name or forgot the prefix get a useful +/// signal instead of a generic "missing Anthropic credentials" wall. +pub(crate) fn anthropic_missing_credentials() -> ApiError { + const PROVIDER: &str = "Anthropic"; + const ENV_VARS: &[&str] = &["ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"]; + match anthropic_missing_credentials_hint() { + Some(hint) => ApiError::missing_credentials_with_hint(PROVIDER, ENV_VARS, hint), + None => ApiError::missing_credentials(PROVIDER, ENV_VARS), + } +} + /// 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. @@ -348,6 +415,9 @@ pub(crate) fn dotenv_value(key: &str) -> Option { #[cfg(test)] mod tests { + use std::ffi::OsString; + use std::sync::{Mutex, OnceLock}; + use serde_json::json; use crate::error::ApiError; @@ -356,11 +426,52 @@ mod tests { }; use super::{ - detect_provider_kind, load_dotenv_file, max_tokens_for_model, - max_tokens_for_model_with_override, model_token_limit, parse_dotenv, - preflight_message_request, resolve_model_alias, ProviderKind, + anthropic_missing_credentials, anthropic_missing_credentials_hint, detect_provider_kind, + load_dotenv_file, max_tokens_for_model, max_tokens_for_model_with_override, + model_token_limit, parse_dotenv, preflight_message_request, resolve_model_alias, + ProviderKind, }; + /// Serializes every test in this module that mutates process-wide + /// environment variables so concurrent test threads cannot observe + /// each other's partially-applied state while probing the foreign + /// provider credential sniffer. + fn env_lock() -> std::sync::MutexGuard<'static, ()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + /// Snapshot-restore guard for a single environment variable. Captures + /// the original value on construction, applies the requested override + /// (set or remove), and restores the original on drop so tests leave + /// the process env untouched even when they panic mid-assertion. + struct EnvVarGuard { + key: &'static str, + original: Option, + } + + impl EnvVarGuard { + fn set(key: &'static str, value: Option<&str>) -> Self { + let original = std::env::var_os(key); + match value { + Some(value) => std::env::set_var(key, value), + None => std::env::remove_var(key), + } + Self { key, original } + } + } + + impl Drop for EnvVarGuard { + fn drop(&mut self) { + match self.original.take() { + Some(value) => std::env::set_var(self.key, value), + None => std::env::remove_var(self.key), + } + } + } + #[test] fn resolves_grok_aliases() { assert_eq!(resolve_model_alias("grok"), "grok-3"); @@ -649,4 +760,225 @@ NO_EQUALS_LINE let _ = std::fs::remove_dir_all(&temp_root); } + + #[test] + fn anthropic_missing_credentials_hint_is_none_when_no_foreign_creds_present() { + // given + let _lock = env_lock(); + let _openai = EnvVarGuard::set("OPENAI_API_KEY", None); + let _xai = EnvVarGuard::set("XAI_API_KEY", None); + let _dashscope = EnvVarGuard::set("DASHSCOPE_API_KEY", None); + + // when + let hint = anthropic_missing_credentials_hint(); + + // then + assert!( + hint.is_none(), + "no hint should be produced when every foreign provider env var is absent, got {hint:?}" + ); + } + + #[test] + fn anthropic_missing_credentials_hint_detects_openai_api_key_and_recommends_openai_prefix() { + // given + let _lock = env_lock(); + let _openai = EnvVarGuard::set("OPENAI_API_KEY", Some("sk-openrouter-varleg")); + let _xai = EnvVarGuard::set("XAI_API_KEY", None); + let _dashscope = EnvVarGuard::set("DASHSCOPE_API_KEY", None); + + // when + let hint = anthropic_missing_credentials_hint() + .expect("OPENAI_API_KEY presence should produce a hint"); + + // then + assert!( + hint.contains("OPENAI_API_KEY is set"), + "hint should name the detected env var so users recognize it: {hint}" + ); + assert!( + hint.contains("OpenAI-compat"), + "hint should identify the target provider: {hint}" + ); + assert!( + hint.contains("openai/"), + "hint should mention the `openai/` prefix routing fix: {hint}" + ); + assert!( + hint.contains("OPENAI_BASE_URL"), + "hint should mention OPENAI_BASE_URL so OpenRouter users see the full picture: {hint}" + ); + } + + #[test] + fn anthropic_missing_credentials_hint_detects_xai_api_key() { + // given + let _lock = env_lock(); + let _openai = EnvVarGuard::set("OPENAI_API_KEY", None); + let _xai = EnvVarGuard::set("XAI_API_KEY", Some("xai-test-key")); + let _dashscope = EnvVarGuard::set("DASHSCOPE_API_KEY", None); + + // when + let hint = anthropic_missing_credentials_hint() + .expect("XAI_API_KEY presence should produce a hint"); + + // then + assert!( + hint.contains("XAI_API_KEY is set"), + "hint should name XAI_API_KEY: {hint}" + ); + assert!( + hint.contains("xAI"), + "hint should identify the xAI provider: {hint}" + ); + assert!( + hint.contains("grok"), + "hint should suggest a grok-prefixed model alias: {hint}" + ); + } + + #[test] + fn anthropic_missing_credentials_hint_detects_dashscope_api_key() { + // given + let _lock = env_lock(); + let _openai = EnvVarGuard::set("OPENAI_API_KEY", None); + let _xai = EnvVarGuard::set("XAI_API_KEY", None); + let _dashscope = EnvVarGuard::set("DASHSCOPE_API_KEY", Some("sk-dashscope-test")); + + // when + let hint = anthropic_missing_credentials_hint() + .expect("DASHSCOPE_API_KEY presence should produce a hint"); + + // then + assert!( + hint.contains("DASHSCOPE_API_KEY is set"), + "hint should name DASHSCOPE_API_KEY: {hint}" + ); + assert!( + hint.contains("DashScope"), + "hint should identify the DashScope provider: {hint}" + ); + assert!( + hint.contains("qwen"), + "hint should suggest a qwen-prefixed model alias: {hint}" + ); + } + + #[test] + fn anthropic_missing_credentials_hint_prefers_openai_when_multiple_foreign_creds_set() { + // given + let _lock = env_lock(); + let _openai = EnvVarGuard::set("OPENAI_API_KEY", Some("sk-openrouter-varleg")); + let _xai = EnvVarGuard::set("XAI_API_KEY", Some("xai-test-key")); + let _dashscope = EnvVarGuard::set("DASHSCOPE_API_KEY", Some("sk-dashscope-test")); + + // when + let hint = anthropic_missing_credentials_hint() + .expect("multiple foreign creds should still produce a hint"); + + // then + assert!( + hint.contains("OPENAI_API_KEY"), + "OpenAI should be prioritized because it is the most common misrouting pattern (OpenRouter users), got: {hint}" + ); + assert!( + !hint.contains("XAI_API_KEY"), + "only the first detected provider should be named to keep the hint focused, got: {hint}" + ); + } + + #[test] + fn anthropic_missing_credentials_builds_error_with_canonical_env_vars_and_no_hint_when_clean() { + // given + let _lock = env_lock(); + let _openai = EnvVarGuard::set("OPENAI_API_KEY", None); + let _xai = EnvVarGuard::set("XAI_API_KEY", None); + let _dashscope = EnvVarGuard::set("DASHSCOPE_API_KEY", None); + + // when + let error = anthropic_missing_credentials(); + + // then + match &error { + ApiError::MissingCredentials { + provider, + env_vars, + hint, + } => { + assert_eq!(*provider, "Anthropic"); + assert_eq!(*env_vars, &["ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"]); + assert!( + hint.is_none(), + "clean environment should not generate a hint, got {hint:?}" + ); + } + other => panic!("expected MissingCredentials variant, got {other:?}"), + } + let rendered = error.to_string(); + assert!( + !rendered.contains(" — hint: "), + "rendered error should be a plain missing-creds message: {rendered}" + ); + } + + #[test] + fn anthropic_missing_credentials_builds_error_with_hint_when_openai_key_is_set() { + // given + let _lock = env_lock(); + let _openai = EnvVarGuard::set("OPENAI_API_KEY", Some("sk-openrouter-varleg")); + let _xai = EnvVarGuard::set("XAI_API_KEY", None); + let _dashscope = EnvVarGuard::set("DASHSCOPE_API_KEY", None); + + // when + let error = anthropic_missing_credentials(); + + // then + match &error { + ApiError::MissingCredentials { + provider, + env_vars, + hint, + } => { + assert_eq!(*provider, "Anthropic"); + assert_eq!(*env_vars, &["ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"]); + let hint_value = hint.as_deref().expect("hint should be populated"); + assert!( + hint_value.contains("OPENAI_API_KEY is set"), + "hint should name the detected env var: {hint_value}" + ); + } + other => panic!("expected MissingCredentials variant, got {other:?}"), + } + let rendered = error.to_string(); + assert!( + rendered.starts_with("missing Anthropic credentials;"), + "canonical base message should still lead the rendered error: {rendered}" + ); + assert!( + rendered.contains(" — hint: I see OPENAI_API_KEY is set"), + "rendered error should carry the env-driven hint: {rendered}" + ); + } + + #[test] + fn anthropic_missing_credentials_hint_ignores_empty_string_values() { + // given + let _lock = env_lock(); + // An empty value is semantically equivalent to "not set" for the + // credential discovery path, so the sniffer must treat it that way + // to avoid false-positive hints for users who intentionally cleared + // a stale export with `OPENAI_API_KEY=`. + let _openai = EnvVarGuard::set("OPENAI_API_KEY", Some("")); + let _xai = EnvVarGuard::set("XAI_API_KEY", None); + let _dashscope = EnvVarGuard::set("DASHSCOPE_API_KEY", None); + + // when + let hint = anthropic_missing_credentials_hint(); + + // then + assert!( + hint.is_none(), + "empty env var should not trigger the hint sniffer, got {hint:?}" + ); + } } diff --git a/rust/crates/api/tests/provider_client_integration.rs b/rust/crates/api/tests/provider_client_integration.rs index c290d00..3d8236e 100644 --- a/rust/crates/api/tests/provider_client_integration.rs +++ b/rust/crates/api/tests/provider_client_integration.rs @@ -22,7 +22,9 @@ fn provider_client_reports_missing_xai_credentials_for_grok_models() { .expect_err("grok requests without XAI_API_KEY should fail fast"); match error { - ApiError::MissingCredentials { provider, env_vars } => { + ApiError::MissingCredentials { + provider, env_vars, .. + } => { assert_eq!(provider, "xAI"); assert_eq!(env_vars, &["XAI_API_KEY"]); }