mirror of
https://github.com/instructkr/claw-code.git
synced 2026-04-09 01:24:49 +08:00
fix(api): auth-provider error copy — prefix-routing hints + sk-ant-* bearer detection — closes ROADMAP #28
Two live users in #claw-code on 2026-04-08 hit adjacent auth confusion: varleg set OPENAI_API_KEY for OpenRouter but prefix routing didn't activate without openai/ model prefix, and stanley078852 put sk-ant-* in ANTHROPIC_AUTH_TOKEN (Bearer path) instead of ANTHROPIC_API_KEY (x-api-key path) and got 401 Invalid bearer token. Changes: 1. ApiError::MissingCredentials gained optional hint field (error.rs) 2. anthropic_missing_credentials_hint() sniffs OPENAI/XAI/DASHSCOPE env vars and suggests prefix routing when present (providers/mod.rs) 3. All 4 Anthropic auth paths wire the hint helper (anthropic.rs) 4. 401 + sk-ant-* in bearer token detected and hint appended 5. 'Which env var goes where' section added to USAGE.md Tests: unit tests for all three improvements (no HTTP calls needed). Workspace: all tests green, fmt clean, clippy warnings-only. Source: live users varleg + stanley078852 in #claw-code 2026-04-08. Co-authored-by: gaebal-gajae <gaebal-gajae@layofflabs.com>
This commit is contained in:
@@ -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<String>,
|
||||
},
|
||||
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
|
||||
/// <provider> 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<String>,
|
||||
) -> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String, ApiError> {
|
||||
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(_)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -291,6 +291,73 @@ fn estimate_serialized_tokens<T: Serialize>(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<String> {
|
||||
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<String> {
|
||||
|
||||
#[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<Mutex<()>> = 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<OsString>,
|
||||
}
|
||||
|
||||
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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user