use crate::chat::stream::Event;
use crate::chat::{
ChatRequest, ChatResponse, ContentBlock, Effort, ImageSource, Message, Role, StopReason,
Thinking,
};
use crate::cost::Usage;
use crate::error::{Error, Result};
use crate::model::{ModelId, Reach};
use crate::providers::api::{ApiProvider, Protocol, SseFrame};
use crate::registry::Registry;
use crate::secret::Secret;
use crate::transport::HttpTransport;
use serde_json::{json, Value};
use std::sync::Arc;
const API_VERSION: &str = "2023-06-01";
pub const DEFAULT_BASE_URL: &str = "https://api.anthropic.com";
#[derive(Debug, Clone, Copy, Default)]
pub struct Messages;
pub type Anthropic = ApiProvider<Messages>;
#[cfg(feature = "reqwest")]
#[cfg_attr(docsrs, doc(cfg(feature = "reqwest")))]
pub fn from_env(timeout: std::time::Duration) -> Result<Anthropic> {
Ok(ApiProvider::new(
Messages,
DEFAULT_BASE_URL,
Arc::new(crate::transport::Reqwest::new(timeout)?),
Secret::from_env("anthropic-api-key", "ANTHROPIC_API_KEY")?,
Reach::FirstPartyApi,
Arc::new(shipped_registry()),
))
}
pub fn with(transport: Arc<dyn HttpTransport>, key: Secret, registry: Arc<Registry>) -> Anthropic {
ApiProvider::new(
Messages,
DEFAULT_BASE_URL,
transport,
key,
Reach::FirstPartyApi,
registry,
)
}
impl Protocol for Messages {
fn id(&self) -> &str {
"anthropic"
}
fn chat_url(&self, base_url: &str, _model: &ModelId) -> String {
format!("{base_url}/v1/messages")
}
fn catalogue_url(&self, base_url: &str) -> Option<String> {
Some(format!("{base_url}/v1/models"))
}
fn headers(&self, key: &Secret) -> Result<Vec<(String, String)>> {
let key = key
.expose_str()
.map_err(|_| Error::Auth("the API key is not valid UTF-8".into()))?;
Ok(vec![
("x-api-key".into(), key.to_string()),
("anthropic-version".into(), API_VERSION.into()),
("content-type".into(), "application/json".into()),
])
}
fn body(&self, request: &ChatRequest) -> Result<Value> {
let max_tokens = request.generation.max_tokens.unwrap_or(4_096);
let mut body = json!({
"model": request.model.as_str(),
"max_tokens": max_tokens,
"messages": messages(request)?,
});
if let Some(system) = &request.system {
body["system"] = json!(system);
}
if let Some(temperature) = request.generation.temperature {
body["temperature"] = json!(temperature);
}
if let Some(top_p) = request.generation.top_p {
body["top_p"] = json!(top_p);
}
if !request.tools.is_empty() {
body["tools"] = json!(request
.tools
.iter()
.map(|tool| json!({
"name": tool.name,
"description": tool.description,
"input_schema": tool.parameters,
}))
.collect::<Vec<_>>());
}
if let Thinking::On(effort) = request.thinking {
body["thinking"] = json!({
"type": "enabled",
"budget_tokens": budget(effort).min(max_tokens.saturating_sub(1)),
});
}
Ok(body)
}
fn read(&self, body: &Value, asked_for: &ModelId) -> Result<ChatResponse> {
let blocks: Vec<ContentBlock> = body
.get("content")
.and_then(Value::as_array)
.map(|blocks| blocks.iter().filter_map(read_block).collect())
.unwrap_or_default();
if blocks.is_empty() {
return Err(Error::Unreadable(
"the reply carried no content this crate could read".into(),
));
}
let mut response = ChatResponse::new(
Message {
role: Role::Assistant,
content: blocks,
},
read_stop(body.get("stop_reason").and_then(Value::as_str)),
read_usage(body.get("usage")),
body.get("model")
.and_then(Value::as_str)
.map(ModelId::from)
.unwrap_or_else(|| asked_for.clone()),
);
if let Some(sequence) = body.get("stop_sequence").and_then(Value::as_str) {
response = response.with_stop_details(sequence);
}
Ok(response)
}
fn read_catalogue(&self, body: &Value) -> Result<Vec<ModelId>> {
let mut ids: Vec<ModelId> = body
.get("data")
.and_then(Value::as_array)
.ok_or_else(|| Error::Unreadable("the model list had no data array".into()))?
.iter()
.filter_map(|m| m.get("id").and_then(Value::as_str))
.map(ModelId::from)
.collect();
ids.sort();
Ok(ids)
}
fn stream_body(&self, request: &ChatRequest) -> Result<Option<Value>> {
let mut body = self.body(request)?;
body["stream"] = json!(true);
Ok(Some(body))
}
fn read_event(&self, frame: &SseFrame, _asked_for: &ModelId) -> Result<Vec<Event>> {
let Some(body) = frame.json() else {
return Err(Error::Unreadable(format!(
"a frame that was not JSON: {}",
frame.data.chars().take(120).collect::<String>()
)));
};
Ok(match frame.event.as_str() {
"message_start" => {
let message = body.get("message");
let mut events = Vec::new();
if let Some(model) = message.and_then(|m| m.get("model")).and_then(Value::as_str) {
events.push(Event::Started {
model: ModelId::from(model),
});
}
let usage = read_usage(message.and_then(|m| m.get("usage")));
if usage.coverage() != crate::cost::UsageCoverage::Absent {
events.push(Event::Metered(usage));
}
events
}
"content_block_start" => match body.get("content_block") {
Some(block) => match block.get("type").and_then(Value::as_str) {
Some("text") | Some("thinking") => Vec::new(),
Some("tool_use") => vec![Event::ToolUseStarted {
id: block
.get("id")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
name: block
.get("name")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
}],
Some(kind) => vec![Event::Opaque {
kind: kind.to_string(),
raw: block.clone(),
}],
None => Vec::new(),
},
None => Vec::new(),
},
"content_block_delta" => match body.get("delta") {
Some(delta) => match delta.get("type").and_then(Value::as_str) {
Some("text_delta") => text_of(delta, "text").map(Event::TextDelta),
Some("thinking_delta") => text_of(delta, "thinking").map(Event::ThinkingDelta),
Some("signature_delta") => {
text_of(delta, "signature").map(Event::ThinkingSignature)
}
Some("input_json_delta") => {
text_of(delta, "partial_json").map(Event::ToolArgumentsDelta)
}
_ => None,
}
.into_iter()
.collect(),
None => Vec::new(),
},
"message_delta" => {
let mut events = Vec::new();
let delta = body.get("delta");
if let Some(reason) = delta.and_then(|d| d.get("stop_reason")) {
if !reason.is_null() {
events.push(Event::Stopped {
reason: read_stop(reason.as_str()),
details: delta
.and_then(|d| d.get("stop_sequence"))
.and_then(Value::as_str)
.map(str::to_string),
});
}
}
let usage = read_usage(body.get("usage"));
if usage.coverage() != crate::cost::UsageCoverage::Absent {
events.push(Event::Metered(usage));
}
events
}
"error" => {
return Err(Error::Transient(format!(
"the stream stopped: {}",
body.get("error")
.and_then(|e| e.get("message"))
.and_then(Value::as_str)
.unwrap_or("no reason given")
)))
}
_ => Vec::new(),
})
}
}
fn encode(bytes: &[u8]) -> String {
use base64::Engine as _;
base64::engine::general_purpose::STANDARD.encode(bytes)
}
fn text_of(delta: &Value, field: &str) -> Option<String> {
delta.get(field).and_then(Value::as_str).map(str::to_string)
}
fn budget(effort: Effort) -> u32 {
match effort {
Effort::Low => 2_048,
Effort::Medium => 8_192,
Effort::High => 24_576,
Effort::XHigh => 49_152,
Effort::Max => 98_304,
}
}
fn messages(request: &ChatRequest) -> Result<Value> {
let mut out = Vec::with_capacity(request.messages.len());
for (index, message) in request.messages.iter().enumerate() {
let mut blocks: Vec<Value> = message.content.iter().map(wire_block).collect();
if request.cache_breakpoints.contains(&index) {
if let Some(last) = blocks.last_mut() {
last["cache_control"] = json!({ "type": "ephemeral" });
}
}
out.push(json!({
"role": match message.role {
Role::User => "user",
Role::Assistant => "assistant",
},
"content": blocks,
}));
}
Ok(Value::Array(out))
}
fn wire_block(block: &ContentBlock) -> Value {
match block {
ContentBlock::Text(text) => json!({ "type": "text", "text": text }),
ContentBlock::Thinking { text, signature } => {
let mut out = json!({ "type": "thinking", "thinking": text });
if let Some(signature) = signature {
out["signature"] = json!(signature);
}
out
}
ContentBlock::ToolUse { id, name, input } => {
json!({ "type": "tool_use", "id": id, "name": name, "input": input })
}
ContentBlock::Image { media_type, source } => match source {
ImageSource::Bytes(bytes) => json!({
"type": "image",
"source": {
"type": "base64",
"media_type": media_type,
"data": encode(bytes),
},
}),
ImageSource::Url(url) => json!({
"type": "image",
"source": { "type": "url", "url": url },
}),
},
ContentBlock::Opaque { raw, .. } => raw.clone(),
ContentBlock::ToolResult {
tool_use_id,
content,
is_error,
} => json!({
"type": "tool_result",
"tool_use_id": tool_use_id,
"content": content,
"is_error": is_error,
}),
}
}
fn read_block(value: &Value) -> Option<ContentBlock> {
match value.get("type")?.as_str()? {
"text" => Some(ContentBlock::Text(value.get("text")?.as_str()?.to_string())),
"thinking" => Some(ContentBlock::Thinking {
text: value
.get("thinking")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
signature: value
.get("signature")
.and_then(Value::as_str)
.map(str::to_string),
}),
"tool_use" => Some(ContentBlock::ToolUse {
id: value.get("id")?.as_str()?.to_string(),
name: value.get("name")?.as_str()?.to_string(),
input: value.get("input").cloned().unwrap_or(json!({})),
}),
kind => Some(ContentBlock::Opaque {
kind: kind.to_string(),
raw: value.clone(),
}),
}
}
fn read_stop(reason: Option<&str>) -> StopReason {
match reason {
Some("end_turn") => StopReason::EndTurn,
Some("tool_use") => StopReason::ToolUse,
Some("stop_sequence") => StopReason::StopSequence,
Some("max_tokens") => StopReason::MaxTokens,
Some("refusal") => StopReason::Refusal,
Some("pause_turn") => StopReason::PauseTurn,
Some("model_context_window_exceeded") => StopReason::ContextWindowExceeded,
_ => StopReason::Other,
}
}
fn read_usage(value: Option<&Value>) -> Usage {
let Some(usage) = value else {
return Usage::absent();
};
let field = |name: &str| usage.get(name).and_then(Value::as_u64);
let mut out = Usage::absent();
if let Some(n) = field("input_tokens") {
out = out.with_input(n);
}
if let Some(n) = field("cache_read_input_tokens") {
out = out.with_cache_read(n);
}
if let Some(n) = field("cache_creation_input_tokens") {
out = out.with_cache_write(n);
}
if let Some(n) = field("output_tokens") {
out = out.with_output(n);
}
out
}
pub fn shipped_registry() -> Registry {
Registry::parse(include_str!("../../../models/anthropic.toml"))
.unwrap_or_else(|_| Registry::empty("anthropic", Reach::FirstPartyApi))
}
pub fn shipped_prices() -> crate::cost::PriceBook {
crate::cost::PriceBook::parse(include_str!("../../../models/anthropic-prices.toml"))
.unwrap_or_else(|_| crate::cost::PriceBook {
id: "unavailable".into(),
provider: "anthropic".into(),
effective_from: String::new(),
source: String::new(),
verified_at: String::new(),
currency: "USD".into(),
rates: Default::default(),
})
}