pub mod request;
pub mod response;
pub use request::anthropic_tool_choice;
use async_trait::async_trait;
use reqwest::Client;
use serde_json::Value;
use crate::inference::adapter::InferenceAdapter;
use crate::inference::configurator::ResolvedProvider;
use crate::inference::error::InferenceError;
use crate::inference::registry::{ProviderCapabilities, ProviderId};
use crate::inference::types::{ChatRequest, ChatResponse, SecretString, ToolChoice};
pub const ANTHROPIC_BASE_URL: &str = "https://api.anthropic.com/v1";
pub const ANTHROPIC_VERSION: &str = "2023-06-01";
const DEFAULT_MAX_TOKENS: u32 = 4096;
pub struct AnthropicAdapter {
endpoint: String,
api_key: SecretString,
capabilities: ProviderCapabilities,
http: Client,
}
impl AnthropicAdapter {
pub fn new(
base_url: &str,
api_key: SecretString,
capabilities: ProviderCapabilities,
) -> Result<Self, InferenceError> {
let endpoint = format!("{}/messages", base_url.trim_end_matches('/'));
let http = Client::builder()
.use_rustls_tls()
.build()
.map_err(|e| InferenceError::Transport(e.to_string()))?;
Ok(Self {
endpoint,
api_key,
capabilities,
http,
})
}
pub fn endpoint(&self) -> &str {
&self.endpoint
}
}
pub fn build(
resolved: &ResolvedProvider,
base_url: &str,
) -> Result<Box<dyn InferenceAdapter>, InferenceError> {
let key = resolved
.key()
.ok_or(InferenceError::MissingCredential {
provider: ProviderId::Anthropic,
})?
.clone();
Ok(Box::new(AnthropicAdapter::new(
base_url,
key,
*resolved.capabilities(),
)?))
}
pub fn factory(resolved: &ResolvedProvider) -> Result<Box<dyn InferenceAdapter>, InferenceError> {
build(resolved, ANTHROPIC_BASE_URL)
}
#[async_trait]
impl InferenceAdapter for AnthropicAdapter {
fn name(&self) -> &str {
ProviderId::Anthropic.as_str()
}
fn capabilities(&self) -> &ProviderCapabilities {
&self.capabilities
}
fn map_tool_choice(&self, choice: ToolChoice) -> Value {
anthropic_tool_choice(choice)
}
async fn chat(&self, request: &ChatRequest) -> Result<ChatResponse, InferenceError> {
let body = request::build_body(request, DEFAULT_MAX_TOKENS);
let resp = self
.http
.post(&self.endpoint)
.header("x-api-key", self.api_key.expose())
.header("anthropic-version", ANTHROPIC_VERSION)
.header(reqwest::header::CONTENT_TYPE, "application/json")
.json(&body)
.send()
.await
.map_err(|e| InferenceError::Transport(e.to_string()))?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(InferenceError::Api {
status: status.as_u16(),
body,
});
}
let text = resp
.text()
.await
.map_err(|e| InferenceError::Transport(e.to_string()))?;
response::parse(&text)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::inference::types::{ChatMessage, ChatRequest, SecretString};
fn resolved(key: &str) -> ResolvedProvider {
ResolvedProvider::new(
ProviderId::Anthropic,
"anthropic/claude-sonnet-4-5".to_string(),
Some(SecretString::new(key)),
)
}
#[test]
fn factory_builds_named_adapter() {
let adapter = build(&resolved("sk-ant-test"), ANTHROPIC_BASE_URL) .expect("built");
assert_eq!(adapter.name(), "anthropic");
assert!(adapter.supports_prompt_caching());
assert!(!adapter.wants_detailed_usage());
assert_eq!(adapter.capabilities().id, ProviderId::Anthropic);
}
#[test]
fn missing_key_errors() {
let resolved = ResolvedProvider::new(
ProviderId::Anthropic,
"anthropic/claude-sonnet-4-5".to_string(),
None,
);
let Err(err) = build(&resolved, ANTHROPIC_BASE_URL) else {
panic!("expected MissingCredential");
};
assert!(matches!(
err,
InferenceError::MissingCredential {
provider: ProviderId::Anthropic
}
));
}
#[test]
fn endpoint_appends_messages_path() {
let caps = *crate::inference::registry::capabilities(ProviderId::Anthropic);
let a = AnthropicAdapter::new(
"https://api.anthropic.com/v1",
SecretString::new("sk-ant-test"), caps,
)
.expect("build");
assert_eq!(a.endpoint(), "https://api.anthropic.com/v1/messages");
let b = AnthropicAdapter::new(
"https://api.anthropic.com/v1/",
SecretString::new("sk-ant-test"), caps,
)
.expect("build");
assert_eq!(b.endpoint(), "https://api.anthropic.com/v1/messages");
}
#[test]
fn map_tool_choice_uses_anthropic_dialect() {
let adapter = build(&resolved("sk-ant-test"), ANTHROPIC_BASE_URL).expect("built"); assert_eq!(
adapter.map_tool_choice(ToolChoice::Required),
serde_json::json!({"type": "any"})
);
assert_eq!(
adapter.map_tool_choice(ToolChoice::Function("f".into())),
serde_json::json!({"type": "tool", "name": "f"})
);
}
#[tokio::test]
#[ignore = "requires ANTHROPIC_API_KEY; skipped in CI"]
async fn live_anthropic_call() {
let Ok(key) = std::env::var("ANTHROPIC_API_KEY") else {
eprintln!("ANTHROPIC_API_KEY not set — skipping live test");
return;
};
if key.trim().is_empty() {
eprintln!("ANTHROPIC_API_KEY is empty — skipping live test");
return;
}
let resolved = ResolvedProvider::new(
ProviderId::Anthropic,
"anthropic/claude-3-5-haiku-latest".to_string(),
Some(SecretString::new(key)),
);
let adapter = build(&resolved, ANTHROPIC_BASE_URL).expect("build adapter");
let mut req = ChatRequest::new(
"claude-3-5-haiku-latest",
vec![
ChatMessage::system("You are a concise assistant."),
ChatMessage::user("Reply with exactly the word: pong"),
],
);
req.temperature = Some(0.0);
req.max_tokens = Some(16);
let resp = adapter.chat(&req).await.expect("live chat");
let text = resp.first_text().expect("assistant text");
assert!(!text.is_empty(), "assistant text was empty");
assert!(
resp.usage().prompt_tokens > 0,
"prompt_tokens should be > 0"
);
eprintln!(
"live anthropic ok — text: {text:?}, usage: {:?}",
resp.usage()
);
}
}