use std::sync::Arc;
pub const MESSAGES_ENDPOINT: &str = "https://api.anthropic.com/v1/messages";
pub const ANTHROPIC_VERSION: &str = "2023-06-01";
pub const DEFAULT_MODEL: &str = "claude-opus-4-8";
pub const MODELS: &[&str] = &["claude-opus-4-8", "claude-sonnet-5", "claude-haiku-4-5"];
pub const MAX_TOKENS: u32 = 4096;
#[derive(Clone, Debug, serde::Serialize)]
pub struct ClaudeMessage {
pub role: String,
pub content: String,
}
impl ClaudeMessage {
pub fn user(content: impl Into<String>) -> Self {
Self { role: "user".into(), content: content.into() }
}
pub fn assistant(content: impl Into<String>) -> Self {
Self { role: "assistant".into(), content: content.into() }
}
}
#[derive(Clone, Debug, serde::Serialize)]
pub struct ClaudeRequest {
pub model: String,
pub max_tokens: u32,
pub messages: Vec<ClaudeMessage>,
#[serde(skip)]
pub api_key: Option<String>,
}
impl ClaudeRequest {
pub fn new(model: impl Into<String>, messages: Vec<ClaudeMessage>) -> Self {
Self { model: model.into(), max_tokens: MAX_TOKENS, messages, api_key: None }
}
pub fn with_api_key(mut self, key: Option<String>) -> Self {
self.api_key = key.filter(|k| !k.trim().is_empty());
self
}
}
#[derive(Clone, Debug)]
pub enum ClaudeError {
NoApiKey,
FeatureDisabled,
Transport(String),
Api { status: u16, message: String },
Refusal(String),
Decode(String),
}
impl std::fmt::Display for ClaudeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ClaudeError::NoApiKey => write!(
f,
"ANTHROPIC_API_KEY is not set — export it, then re-send \
(e.g. `export ANTHROPIC_API_KEY=sk-ant-…`)."
),
ClaudeError::FeatureDisabled => write!(
f,
"the `claude` feature is disabled in this build — rebuild with \
default features to talk to the Anthropic API."
),
ClaudeError::Transport(e) => write!(f, "request failed: {e}"),
ClaudeError::Api { status, message } => {
write!(f, "Anthropic API error {status}: {message}")
}
ClaudeError::Refusal(cat) => {
write!(f, "the model declined the request (refusal: {cat}).")
}
ClaudeError::Decode(e) => write!(f, "could not read the response: {e}"),
}
}
}
impl std::error::Error for ClaudeError {}
pub fn api_key_present() -> bool {
std::env::var("ANTHROPIC_API_KEY").map(|k| !k.is_empty()).unwrap_or(false)
}
pub trait ClaudeTransport: Send + Sync {
fn send(&self, req: &ClaudeRequest) -> Result<String, ClaudeError>;
}
#[cfg(feature = "claude")]
pub struct HttpTransport;
#[cfg(feature = "claude")]
impl ClaudeTransport for HttpTransport {
fn send(&self, req: &ClaudeRequest) -> Result<String, ClaudeError> {
let key = req
.api_key
.clone()
.filter(|k| !k.trim().is_empty())
.or_else(|| std::env::var("ANTHROPIC_API_KEY").ok().filter(|k| !k.is_empty()))
.ok_or(ClaudeError::NoApiKey)?;
let client = reqwest::blocking::Client::new();
let resp = client
.post(MESSAGES_ENDPOINT)
.header("x-api-key", key)
.header("anthropic-version", ANTHROPIC_VERSION)
.header("content-type", "application/json")
.json(req)
.send()
.map_err(|e| ClaudeError::Transport(e.to_string()))?;
let status = resp.status();
let body: serde_json::Value =
resp.json().map_err(|e| ClaudeError::Decode(e.to_string()))?;
if !status.is_success() {
let message = body
.get("error")
.and_then(|e| e.get("message"))
.and_then(|m| m.as_str())
.unwrap_or("unknown error")
.to_string();
return Err(ClaudeError::Api { status: status.as_u16(), message });
}
if body.get("stop_reason").and_then(|s| s.as_str()) == Some("refusal") {
let cat = body
.get("stop_details")
.and_then(|d| d.get("category"))
.and_then(|c| c.as_str())
.unwrap_or("safety")
.to_string();
return Err(ClaudeError::Refusal(cat));
}
assemble_text(&body).ok_or_else(|| {
ClaudeError::Decode("no text block in the response content".into())
})
}
}
pub fn assemble_text(body: &serde_json::Value) -> Option<String> {
let blocks = body.get("content")?.as_array()?;
let text: String = blocks
.iter()
.filter(|b| b.get("type").and_then(|t| t.as_str()) == Some("text"))
.filter_map(|b| b.get("text").and_then(|t| t.as_str()))
.collect();
Some(text)
}
pub fn default_transport() -> Arc<dyn ClaudeTransport> {
#[cfg(feature = "claude")]
{
Arc::new(HttpTransport)
}
#[cfg(not(feature = "claude"))]
{
Arc::new(DisabledTransport)
}
}
#[cfg(not(feature = "claude"))]
pub struct DisabledTransport;
#[cfg(not(feature = "claude"))]
impl ClaudeTransport for DisabledTransport {
fn send(&self, _req: &ClaudeRequest) -> Result<String, ClaudeError> {
Err(ClaudeError::FeatureDisabled)
}
}
pub struct FakeTransport {
reply: Result<String, ClaudeError>,
seen: std::sync::Mutex<Vec<ClaudeRequest>>,
}
impl FakeTransport {
pub fn replying(reply: impl Into<String>) -> Self {
Self { reply: Ok(reply.into()), seen: std::sync::Mutex::new(Vec::new()) }
}
pub fn failing(err: ClaudeError) -> Self {
Self { reply: Err(err), seen: std::sync::Mutex::new(Vec::new()) }
}
pub fn requests(&self) -> Vec<ClaudeRequest> {
self.seen.lock().unwrap().clone()
}
}
impl ClaudeTransport for FakeTransport {
fn send(&self, req: &ClaudeRequest) -> Result<String, ClaudeError> {
self.seen.lock().unwrap().push(req.clone());
self.reply.clone()
}
}