Detect which auth strategy the Claude Code CLI will use, and classify auth-related CLI failures.
This is a standalone, env-only introspection module. It is distinct
from ClaudeWrapper.Commands.Auth, which shells out to claude auth
(login/logout/status/setup-token). Nothing here spawns a subprocess or
reads the filesystem.
Detection
Claude Code resolves auth at invocation time by inspecting a few
environment variables, falling back to credentials stored under
~/.claude/ when none are set. detect/0 mirrors that precedence as a
cheap, synchronous, env-only check so hosts can introspect the active
mode before spawning a turn.
It is not a liveness check -- a reported :subscription strategy
only means "no env auth set"; the user might not have run claude login
yet. Use ClaudeWrapper.Commands.Auth.status/1 for that.
Precedence (first match wins):
CLAUDE_CODE_USE_BEDROCKtruthy ->:bedrockCLAUDE_CODE_USE_VERTEXtruthy ->:vertexANTHROPIC_API_KEYnon-empty ->:api_keyCLAUDE_CODE_OAUTH_TOKENnon-empty ->:oauth_token- Otherwise ->
:subscription
Cloud-provider strategies (Bedrock, Vertex) take precedence because they redirect ALL traffic regardless of API key presence.
Failure classification
classify_failure/3 inspects a failed claude invocation and decides
whether it looks auth-shaped. It returns the matching
auth_error_kind/0 atom, or nil when the failure is not
auth-related. It is conservative on purpose: a false positive turns a
legitimate non-auth failure into an "auth error" surprise, so the
classifier prefers to miss an auth error rather than misclassify a
non-auth one.
Two recent fixes are preserved from the Rust reference:
- A model-not-found / model-access failure (a bad
--modelid) must not be classified as auth, even when it carries a 403/404 HTTP status. It returnsnil. - A
--bareinvocation with a missing API key prints "Not logged in" to stdout with empty stderr; that surfaces as:not_authenticated.
Example
summary = ClaudeWrapper.Auth.detect()
summary.strategy
#=> :subscription
ClaudeWrapper.Auth.classify_failure(1, "", "HTTP 401 Unauthorized")
#=> :invalid_credentials
ClaudeWrapper.Auth.classify_failure(1, "no match found", "")
#=> nil
Summary
Types
Best-effort classification of an auth-related CLI failure.
Active auth strategy, as inferred from the host environment.
Types
@type auth_error_kind() ::
:not_authenticated
| :expired
| :invalid_credentials
| :rate_limit
| :provider_error
| :other
Best-effort classification of an auth-related CLI failure.
:not_authenticated-- no credentials at all. Fix: runclaude loginor set one of the auth env vars.:expired-- stored credentials existed but are expired. Fix: re-runclaude login.:invalid_credentials-- credentials were presented but rejected (wrong/revoked key, stale token).:rate_limit-- authenticated but rejected for rate limit / quota / billing reasons. Different remediation: wait, top up, or switch keys -- not "log in again.":provider_error-- Bedrock or Vertex provider error (cloud creds missing or rejected). The fix lives in the cloud provider's auth.:other-- looked auth-shaped (HTTP 401/403, the word "auth") but did not match a more specific pattern.
@type strategy() :: :bedrock | :vertex | :api_key | :oauth_token | :subscription
Active auth strategy, as inferred from the host environment.
:bedrock--CLAUDE_CODE_USE_BEDROCKis truthy. Requests route to AWS Bedrock; AWS credentials are resolved separately by the SDK.:vertex--CLAUDE_CODE_USE_VERTEXis truthy. Requests route to Google Vertex; GCP credentials are resolved separately.:api_key--ANTHROPIC_API_KEYis set. Direct API access.:oauth_token--CLAUDE_CODE_OAUTH_TOKENis set (typically fromclaude setup-token).:subscription-- no auth env var set. The CLI looks for stored credentials under~/.claude/. May or may not actually be authenticated -- this reports "the env doesn't pin anything," not "you are logged in."
See the module docs for precedence rules.
Functions
@spec classify_failure(integer(), String.t(), String.t()) :: auth_error_kind() | nil
Inspect a failed claude invocation and decide whether it looks
auth-shaped.
Returns the matching auth_error_kind/0 atom only when the patterns
are confident enough to risk relabeling, and nil otherwise.
exit_code is accepted for parity with the Rust signature and future
use; the current heuristics match only against the lowercased
stdout/stderr text. The patterns are intentionally narrow:
- model-not-found / model-access phrasing ->
nil(a bad--modelid is a typo, not a credential problem, even with a 403/404 status) - "not authenticated" / "not logged in" / "claude login" /
"run /login" / "no credentials" / "no auth" ->
:not_authenticated - "expired" / "session has expired" / "token expired" ->
:expired - "invalid api key" / "invalid token" / "401" / "unauthorized" /
"403" / "forbidden" ->
:invalid_credentials - "rate limit" / "too many requests" / "429" / "quota" ->
:rate_limit - "bedrock" or "vertex" alongside an auth signal ->
:provider_error - a bare "auth" / "credential" mention in stderr ->
:other
@spec detect() :: ClaudeWrapper.Auth.Summary.t()
Detect the active auth strategy from the current process environment.
Cheap; no subprocess, no filesystem reads. Reads the real process env
via System.get_env/0.
@spec detect_from(%{optional(String.t()) => String.t()}) :: ClaudeWrapper.Auth.Summary.t()
Like detect/0, but reads from a caller-provided env map.
Exposed for tests and for hosts that want to introspect a child
environment they are about to spawn under. The map is keyed by env-var
name, matching the shape of System.get_env/0.