use std::borrow::Cow;
use serde_json::Value;
use crate::error::Result;
use crate::provider::Provider;
use crate::types::ChatCompletionChunk;
mod request;
mod response;
mod stream;
static ANTHROPIC_EXTRA_HEADERS: &[(&str, &str)] = &[("anthropic-version", "2023-06-01")];
const DEFAULT_MAX_TOKENS: u64 = 4096;
const HOSTED_TOOL_TYPES: &[&str] = &[
"computer_20241022",
"computer_use_20250124",
"web_search_20250305",
"code_execution_20250522",
];
const BETA_COMPUTER_USE: &str = "computer-use-2025-01-24";
const BETA_WEB_SEARCH: &str = "web-search-2025-03-05";
const BETA_CODE_EXECUTION: &str = "code-execution-2025-05-22";
const BETA_THINKING: &str = "thinking-2025-04-14";
const BETA_PROMPT_CACHING: &str = "prompt-caching-2024-07-31";
const BETA_PDFS: &str = "pdfs-2024-09-25";
pub struct AnthropicProvider {
base_url: String,
}
const DEFAULT_ANTHROPIC_BASE_URL: &str = "https://api.anthropic.com/v1";
impl AnthropicProvider {
#[must_use]
pub fn new() -> Self {
Self {
base_url: DEFAULT_ANTHROPIC_BASE_URL.to_owned(),
}
}
#[must_use]
pub fn with_base_url(base_url: impl Into<String>) -> Self {
let trimmed = base_url.into().trim_end_matches('/').to_owned();
let base_url = if trimmed.is_empty() {
DEFAULT_ANTHROPIC_BASE_URL.to_owned()
} else {
trimmed
};
Self { base_url }
}
}
impl Default for AnthropicProvider {
fn default() -> Self {
Self::new()
}
}
impl Provider for AnthropicProvider {
fn name(&self) -> &str {
"anthropic"
}
fn base_url(&self) -> &str {
&self.base_url
}
fn env_var(&self) -> Option<&str> {
Some("ANTHROPIC_API_KEY")
}
fn auth_header<'a>(&'a self, api_key: &'a str) -> Option<(Cow<'static, str>, Cow<'a, str>)> {
Some((Cow::Borrowed("x-api-key"), Cow::Borrowed(api_key)))
}
fn extra_headers(&self) -> &'static [(&'static str, &'static str)] {
ANTHROPIC_EXTRA_HEADERS
}
fn dynamic_headers(&self, body: &serde_json::Value) -> Vec<(String, String)> {
let mut betas: Vec<&str> = Vec::new();
if body.get("thinking").is_some() {
betas.push(BETA_THINKING);
}
if let Some(tools) = body.get("tools").and_then(|t| t.as_array()) {
for tool in tools {
let tool_type = tool.get("type").and_then(|t| t.as_str()).unwrap_or("");
match tool_type {
"computer_20241022" | "computer_use_20250124" if !betas.contains(&BETA_COMPUTER_USE) => {
betas.push(BETA_COMPUTER_USE);
}
"web_search_20250305" if !betas.contains(&BETA_WEB_SEARCH) => {
betas.push(BETA_WEB_SEARCH);
}
"code_execution_20250522" if !betas.contains(&BETA_CODE_EXECUTION) => {
betas.push(BETA_CODE_EXECUTION);
}
_ => {}
}
}
}
if body_contains_cache_control(body) && !betas.contains(&BETA_PROMPT_CACHING) {
betas.push(BETA_PROMPT_CACHING);
}
if body_contains_document_block(body) && !betas.contains(&BETA_PDFS) {
betas.push(BETA_PDFS);
}
if betas.is_empty() {
vec![]
} else {
vec![("anthropic-beta".to_owned(), betas.join(","))]
}
}
fn matches_model(&self, model: &str) -> bool {
model.starts_with("claude-") || model.starts_with("anthropic/")
}
fn strip_model_prefix<'m>(&self, model: &'m str) -> &'m str {
model.strip_prefix("anthropic/").unwrap_or(model)
}
fn chat_completions_path(&self) -> &str {
"/messages"
}
fn transform_request(&self, body: &mut Value) -> Result<()> {
request::transform_request(body)
}
fn transform_response(&self, body: &mut Value) -> Result<()> {
response::transform_response(body)
}
fn parse_stream_event(&self, event_data: &str) -> Result<Option<ChatCompletionChunk>> {
stream::parse_stream_event(event_data)
}
}
fn sanitize_tool_call_id(id: &str) -> Cow<'_, str> {
if id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-') {
Cow::Borrowed(id)
} else {
Cow::Owned(
id.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
c
} else {
'_'
}
})
.collect(),
)
}
}
fn is_hosted_tool_type(tool_type: &str) -> bool {
HOSTED_TOOL_TYPES.contains(&tool_type)
}
fn body_contains_cache_control(body: &Value) -> bool {
match body {
Value::Object(map) => {
if map.contains_key("cache_control") {
return true;
}
map.values().any(body_contains_cache_control)
}
Value::Array(arr) => arr.iter().any(body_contains_cache_control),
_ => false,
}
}
fn body_contains_document_block(body: &Value) -> bool {
body.get("messages").and_then(|m| m.as_array()).is_some_and(|messages| {
messages.iter().any(|msg| {
msg.get("content").and_then(|c| c.as_array()).is_some_and(|content| {
content
.iter()
.any(|part| part.get("type").and_then(|t| t.as_str()) == Some("document"))
})
})
})
}
fn map_stop_reason(stop_reason: &str) -> &'static str {
match stop_reason {
"end_turn" | "stop_sequence" => "stop",
"tool_use" => "tool_calls",
"max_tokens" => "length",
"content_filtered" | "refusal" => "content_filter",
_ => "stop",
}
}
#[cfg(test)]
mod feature_tests;
#[cfg(test)]
mod tests;