Skip to content

Commit ff1df4c

Browse files
code-yeongyugaebal-gajae
andcommitted
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>
1 parent efa24ed commit ff1df4c

5 files changed

Lines changed: 686 additions & 23 deletions

File tree

USAGE.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,20 @@ cd rust
109109
./target/debug/claw logout
110110
```
111111

112+
### Which env var goes where
113+
114+
`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.
115+
116+
| Credential shape | Env var | HTTP header | Typical source |
117+
|---|---|---|---|
118+
| `sk-ant-*` API key | `ANTHROPIC_API_KEY` | `x-api-key: sk-ant-...` | [console.anthropic.com](https://console.anthropic.com) |
119+
| OAuth access token (opaque) | `ANTHROPIC_AUTH_TOKEN` | `Authorization: Bearer ...` | `claw login` or an Anthropic-compatible proxy that mints Bearer tokens |
120+
| 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) |
121+
122+
**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.
123+
124+
**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.
125+
112126
## Local Models
113127

114128
`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`.

rust/crates/api/src/error.rs

Lines changed: 88 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ pub enum ApiError {
2222
MissingCredentials {
2323
provider: &'static str,
2424
env_vars: &'static [&'static str],
25+
/// Optional, runtime-computed hint appended to the error Display
26+
/// output. Populated when the provider resolver can infer what the
27+
/// user probably intended (e.g. an OpenAI key is set but Anthropic
28+
/// was selected because no Anthropic credentials exist).
29+
hint: Option<String>,
2530
},
2631
ContextWindowExceeded {
2732
model: String,
@@ -66,7 +71,29 @@ impl ApiError {
6671
provider: &'static str,
6772
env_vars: &'static [&'static str],
6873
) -> Self {
69-
Self::MissingCredentials { provider, env_vars }
74+
Self::MissingCredentials {
75+
provider,
76+
env_vars,
77+
hint: None,
78+
}
79+
}
80+
81+
/// Build a `MissingCredentials` error carrying an extra, runtime-computed
82+
/// hint string that the Display impl appends after the canonical "missing
83+
/// <provider> credentials" message. Used by the provider resolver to
84+
/// suggest the likely fix when the user has credentials for a different
85+
/// provider already in the environment.
86+
#[must_use]
87+
pub fn missing_credentials_with_hint(
88+
provider: &'static str,
89+
env_vars: &'static [&'static str],
90+
hint: impl Into<String>,
91+
) -> Self {
92+
Self::MissingCredentials {
93+
provider,
94+
env_vars,
95+
hint: Some(hint.into()),
96+
}
7097
}
7198

7299
/// Build a `Self::Json` enriched with the provider name, the model that
@@ -204,7 +231,11 @@ impl ApiError {
204231
impl Display for ApiError {
205232
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
206233
match self {
207-
Self::MissingCredentials { provider, env_vars } => {
234+
Self::MissingCredentials {
235+
provider,
236+
env_vars,
237+
hint,
238+
} => {
208239
write!(
209240
f,
210241
"missing {provider} credentials; export {} before calling the {provider} API",
@@ -223,6 +254,9 @@ impl Display for ApiError {
223254
)?;
224255
}
225256
}
257+
if let Some(hint) = hint {
258+
write!(f, " — hint: {hint}")?;
259+
}
226260
Ok(())
227261
}
228262
Self::ContextWindowExceeded {
@@ -483,4 +517,56 @@ mod tests {
483517
assert_eq!(error.safe_failure_class(), "context_window");
484518
assert_eq!(error.request_id(), Some("req_ctx_123"));
485519
}
520+
521+
#[test]
522+
fn missing_credentials_without_hint_renders_the_canonical_message() {
523+
// given
524+
let error = ApiError::missing_credentials(
525+
"Anthropic",
526+
&["ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"],
527+
);
528+
529+
// when
530+
let rendered = error.to_string();
531+
532+
// then
533+
assert!(
534+
rendered.starts_with(
535+
"missing Anthropic credentials; export ANTHROPIC_AUTH_TOKEN or ANTHROPIC_API_KEY before calling the Anthropic API"
536+
),
537+
"rendered error should lead with the canonical missing-credential message: {rendered}"
538+
);
539+
assert!(
540+
!rendered.contains(" — hint: "),
541+
"no hint should be appended when none is supplied: {rendered}"
542+
);
543+
}
544+
545+
#[test]
546+
fn missing_credentials_with_hint_appends_the_hint_after_base_message() {
547+
// given
548+
let error = ApiError::missing_credentials_with_hint(
549+
"Anthropic",
550+
&["ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"],
551+
"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.",
552+
);
553+
554+
// when
555+
let rendered = error.to_string();
556+
557+
// then
558+
assert!(
559+
rendered.starts_with("missing Anthropic credentials;"),
560+
"hint should be appended, not replace the base message: {rendered}"
561+
);
562+
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.";
563+
assert!(
564+
rendered.ends_with(hint_marker),
565+
"rendered error should end with the hint: {rendered}"
566+
);
567+
// Classification semantics are unaffected by the presence of a hint.
568+
assert_eq!(error.safe_failure_class(), "provider_auth");
569+
assert!(!error.is_retryable());
570+
assert_eq!(error.request_id(), None);
571+
}
486572
}

0 commit comments

Comments
 (0)