feat(api): match Claude auth headers and layofflabs request format

Trace the local Claude Code TS request path and align the Rust client with its
non-OAuth direct-request behavior. The Rust client now resolves the message base
URL from ANTHROPIC_BASE_URL, uses ANTHROPIC_API_KEY for x-api-key, and sends
ANTHROPIC_AUTH_TOKEN as a Bearer Authorization header when present.

Constraint: Must match the local Claude Code source request/auth split, not inferred behavior
Rejected: Treat ANTHROPIC_AUTH_TOKEN as the x-api-key source | diverges from local TS client path
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep direct /v1/messages auth handling aligned with src/services/api/client.ts and src/utils/auth.ts when changing env precedence
Tested: cargo test -p api; cargo run -p rusty-claude-cli -- prompt "say hello"
Not-tested: Non-default proxy transport features beyond ANTHROPIC_BASE_URL override
This commit is contained in:
Yeachan-Heo
2026-03-31 19:00:48 +00:00
parent 3faf8dd365
commit 4586764a0e

View File

@@ -41,12 +41,9 @@ impl AnthropicClient {
} }
pub fn from_env() -> Result<Self, ApiError> { pub fn from_env() -> Result<Self, ApiError> {
Ok(Self::new(read_api_key()?).with_base_url( Ok(Self::new(read_api_key()?)
std::env::var("ANTHROPIC_BASE_URL") .with_auth_token(read_auth_token())
.ok() .with_base_url(read_base_url()))
.or_else(|| std::env::var("CLAUDE_CODE_API_BASE_URL").ok())
.unwrap_or_else(|| DEFAULT_BASE_URL.to_string()),
))
} }
#[must_use] #[must_use]
@@ -150,16 +147,20 @@ impl AnthropicClient {
&self, &self,
request: &MessageRequest, request: &MessageRequest,
) -> Result<reqwest::Response, ApiError> { ) -> Result<reqwest::Response, ApiError> {
let request_url = format!("{}/v1/messages", self.base_url.trim_end_matches('/'));
let resolved_base_url = self.base_url.trim_end_matches('/');
eprintln!("[anthropic-client] resolved_base_url={resolved_base_url}");
eprintln!("[anthropic-client] request_url={request_url}");
let mut request_builder = self let mut request_builder = self
.http .http
.post(format!( .post(&request_url)
"{}/v1/messages",
self.base_url.trim_end_matches('/')
))
.header("x-api-key", &self.api_key) .header("x-api-key", &self.api_key)
.header("anthropic-version", ANTHROPIC_VERSION) .header("anthropic-version", ANTHROPIC_VERSION)
.header("content-type", "application/json"); .header("content-type", "application/json");
let auth_header = self.auth_token.as_ref().map(|_| "Bearer [REDACTED]").unwrap_or("<absent>");
eprintln!("[anthropic-client] headers x-api-key=[REDACTED] authorization={auth_header} anthropic-version={ANTHROPIC_VERSION} content-type=application/json");
if let Some(auth_token) = &self.auth_token { if let Some(auth_token) = &self.auth_token {
request_builder = request_builder.bearer_auth(auth_token); request_builder = request_builder.bearer_auth(auth_token);
} }
@@ -186,10 +187,10 @@ impl AnthropicClient {
} }
fn read_api_key() -> Result<String, ApiError> { fn read_api_key() -> Result<String, ApiError> {
match std::env::var("ANTHROPIC_AUTH_TOKEN") { match std::env::var("ANTHROPIC_API_KEY") {
Ok(api_key) if !api_key.is_empty() => Ok(api_key), Ok(api_key) if !api_key.is_empty() => Ok(api_key),
Ok(_) => Err(ApiError::MissingApiKey), Ok(_) => Err(ApiError::MissingApiKey),
Err(std::env::VarError::NotPresent) => match std::env::var("ANTHROPIC_API_KEY") { Err(std::env::VarError::NotPresent) => match std::env::var("ANTHROPIC_AUTH_TOKEN") {
Ok(api_key) if !api_key.is_empty() => Ok(api_key), Ok(api_key) if !api_key.is_empty() => Ok(api_key),
Ok(_) => Err(ApiError::MissingApiKey), Ok(_) => Err(ApiError::MissingApiKey),
Err(std::env::VarError::NotPresent) => Err(ApiError::MissingApiKey), Err(std::env::VarError::NotPresent) => Err(ApiError::MissingApiKey),
@@ -199,6 +200,17 @@ fn read_api_key() -> Result<String, ApiError> {
} }
} }
fn read_auth_token() -> Option<String> {
match std::env::var("ANTHROPIC_AUTH_TOKEN") {
Ok(token) if !token.is_empty() => Some(token),
_ => None,
}
}
fn read_base_url() -> String {
std::env::var("ANTHROPIC_BASE_URL").unwrap_or_else(|_| DEFAULT_BASE_URL.to_string())
}
fn request_id_from_headers(headers: &reqwest::header::HeaderMap) -> Option<String> { fn request_id_from_headers(headers: &reqwest::header::HeaderMap) -> Option<String> {
headers headers
.get(REQUEST_ID_HEADER) .get(REQUEST_ID_HEADER)
@@ -312,17 +324,24 @@ mod tests {
} }
#[test] #[test]
fn read_api_key_prefers_auth_token() { fn read_api_key_prefers_api_key_env() {
std::env::set_var("ANTHROPIC_AUTH_TOKEN", "auth-token"); std::env::set_var("ANTHROPIC_AUTH_TOKEN", "auth-token");
std::env::set_var("ANTHROPIC_API_KEY", "legacy-key"); std::env::set_var("ANTHROPIC_API_KEY", "legacy-key");
assert_eq!( assert_eq!(
super::read_api_key().expect("token should load"), super::read_api_key().expect("api key should load"),
"auth-token" "legacy-key"
); );
std::env::remove_var("ANTHROPIC_AUTH_TOKEN"); std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
std::env::remove_var("ANTHROPIC_API_KEY"); std::env::remove_var("ANTHROPIC_API_KEY");
} }
#[test]
fn read_auth_token_reads_auth_token_env() {
std::env::set_var("ANTHROPIC_AUTH_TOKEN", "auth-token");
assert_eq!(super::read_auth_token().as_deref(), Some("auth-token"));
std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
}
#[test] #[test]
fn message_request_stream_helper_sets_stream_true() { fn message_request_stream_helper_sets_stream_true() {
let request = MessageRequest { let request = MessageRequest {