use crate::adapter::adapters::support::get_api_key;
use crate::adapter::anthropic::AnthropicStreamer;
use crate::adapter::{Adapter, AdapterKind, ServiceType, WebRequestData};
use crate::chat::{
Binary, BinarySource, ChatOptionsSet, ChatRequest, ChatResponse, ChatRole, ChatStream, ChatStreamResponse,
ContentPart, MessageContent, PromptTokensDetails, ReasoningEffort, ToolCall, Usage,
};
use crate::resolver::{AuthData, Endpoint};
use crate::webc::{EventSourceStream, WebResponse};
use crate::{Headers, ModelIden};
use crate::{Result, ServiceTarget};
use reqwest::RequestBuilder;
use serde_json::{Value, json};
use tracing::warn;
use value_ext::JsonValueExt;
pub struct AnthropicAdapter;
const REASONING_LOW: u32 = 1024;
const REASONING_MEDIUM: u32 = 8000;
const REASONING_HIGH: u32 = 24000;
fn insert_anthropic_thinking_budget_value(payload: &mut Value, effort: &ReasoningEffort) -> Result<()> {
let thinking_budget = match effort {
ReasoningEffort::None => None,
ReasoningEffort::Budget(budget) => Some(*budget),
ReasoningEffort::Low | ReasoningEffort::Minimal => Some(REASONING_LOW),
ReasoningEffort::Medium => Some(REASONING_MEDIUM),
ReasoningEffort::High => Some(REASONING_HIGH),
};
if let Some(thinking_budget) = thinking_budget {
payload.x_insert(
"thinking",
json!({
"type": "enabled",
"budget_tokens": thinking_budget
}),
)?;
}
Ok(())
}
const MAX_TOKENS_64K: u32 = 64000; const MAX_TOKENS_32K: u32 = 32000; const MAX_TOKENS_8K: u32 = 8192; const MAX_TOKENS_4K: u32 = 4096;
const ANTHROPIC_VERSION: &str = "2023-06-01";
const MODELS: &[&str] = &["claude-opus-4-5", "claude-sonnet-4-5", "claude-haiku-4-5"];
impl AnthropicAdapter {
pub const API_KEY_DEFAULT_ENV_NAME: &str = "ANTHROPIC_API_KEY";
}
impl Adapter for AnthropicAdapter {
fn default_endpoint() -> Endpoint {
const BASE_URL: &str = "https://api.anthropic.com/v1/";
Endpoint::from_static(BASE_URL)
}
fn default_auth() -> AuthData {
AuthData::from_env(Self::API_KEY_DEFAULT_ENV_NAME)
}
async fn all_model_names(_kind: AdapterKind) -> Result<Vec<String>> {
Ok(MODELS.iter().map(|s| s.to_string()).collect())
}
fn get_service_url(_model: &ModelIden, service_type: ServiceType, endpoint: Endpoint) -> Result<String> {
let base_url = endpoint.base_url();
let url = match service_type {
ServiceType::Chat | ServiceType::ChatStream => format!("{base_url}messages"),
ServiceType::Embed => format!("{base_url}embeddings"), };
Ok(url)
}
fn to_web_request_data(
target: ServiceTarget,
service_type: ServiceType,
chat_req: ChatRequest,
options_set: ChatOptionsSet<'_, '_>,
) -> Result<WebRequestData> {
let ServiceTarget { endpoint, auth, model } = target;
let api_key = get_api_key(auth, &model)?;
let url = Self::get_service_url(&model, service_type, endpoint)?;
let headers = Headers::from(vec![
("x-api-key".to_string(), api_key),
("anthropic-beta".to_string(), "effort-2025-11-24".to_string()),
("anthropic-version".to_string(), ANTHROPIC_VERSION.to_string()),
]);
let AnthropicRequestParts {
system,
messages,
tools,
} = Self::into_anthropic_request_parts(chat_req)?;
let (_, raw_model_name) = model.model_name.namespace_and_name();
let (model_name, computed_reasoning_effort) = match (raw_model_name, options_set.reasoning_effort()) {
(model, None) => {
if let Some((prefix, last)) = raw_model_name.rsplit_once('-') {
let reasoning = match last {
"zero" => None,
"None" => Some(ReasoningEffort::Low),
"minimal" => Some(ReasoningEffort::Low),
"low" => Some(ReasoningEffort::Low),
"medium" => Some(ReasoningEffort::Medium),
"high" => Some(ReasoningEffort::High),
_ => None,
};
let model = if reasoning.is_some() { prefix } else { model };
(model, reasoning)
} else {
(model, None)
}
}
(model, Some(effort)) => (model, Some(effort.clone())),
};
let stream = matches!(service_type, ServiceType::ChatStream);
let mut payload = json!({
"model": model_name.to_string(),
"messages": messages,
"stream": stream
});
if let Some(system) = system {
payload.x_insert("system", system)?;
}
if let Some(tools) = tools {
payload.x_insert("/tools", tools)?;
}
if let Some(computed_reasoning_effort) = computed_reasoning_effort {
if model_name.contains("opus-4-5") {
let effort = match computed_reasoning_effort {
ReasoningEffort::Minimal => "low",
ReasoningEffort::Low => "low",
ReasoningEffort::Medium => "medium",
ReasoningEffort::High => "high",
ReasoningEffort::Budget(_) => "",
ReasoningEffort::None => "",
};
if !effort.is_empty() {
payload.x_insert(
"output_config",
json!({
"effort": effort
}),
)?;
}
}
insert_anthropic_thinking_budget_value(&mut payload, &computed_reasoning_effort)?;
}
if let Some(temperature) = options_set.temperature() {
payload.x_insert("temperature", temperature)?;
}
if !options_set.stop_sequences().is_empty() {
payload.x_insert("stop_sequences", options_set.stop_sequences())?;
}
let max_tokens = options_set.max_tokens().unwrap_or_else(|| {
if model_name.contains("claude-sonnet")
|| model_name.contains("claude-haiku")
|| model_name.contains("claude-3-7-sonnet")
|| model_name.contains("claude-opus-4-5")
{
MAX_TOKENS_64K
} else if model_name.contains("claude-opus-4") {
MAX_TOKENS_32K
} else if model_name.contains("claude-3-5") {
MAX_TOKENS_8K
} else if model_name.contains("3-opus") || model_name.contains("3-haiku") {
MAX_TOKENS_4K
}
else {
MAX_TOKENS_64K
}
});
payload.x_insert("max_tokens", max_tokens)?;
if let Some(top_p) = options_set.top_p() {
payload.x_insert("top_p", top_p)?;
}
Ok(WebRequestData { url, headers, payload })
}
fn to_chat_response(
model_iden: ModelIden,
web_response: WebResponse,
_options_set: ChatOptionsSet<'_, '_>,
) -> Result<ChatResponse> {
let WebResponse { mut body, .. } = web_response;
let provider_model_name: Option<String> = body.x_remove("model").ok();
let provider_model_iden = model_iden.from_optional_name(provider_model_name);
let usage = body.x_take::<Value>("usage");
let usage = usage.map(Self::into_usage).unwrap_or_default();
let mut content: MessageContent = MessageContent::default();
let json_content_items: Vec<Value> = body.x_take("content")?;
let mut reasoning_content: Vec<String> = Vec::new();
for mut item in json_content_items {
let typ: &str = item.x_get_as("type")?;
match typ {
"text" => {
let part = ContentPart::from_text(item.x_take::<String>("text")?);
content.push(part);
}
"thinking" => reasoning_content.push(item.x_take("thinking")?),
"tool_use" => {
let call_id = item.x_take::<String>("id")?;
let fn_name = item.x_take::<String>("name")?;
let fn_arguments = item.x_take::<Value>("input").unwrap_or_default();
let tool_call = ToolCall {
call_id,
fn_name,
fn_arguments,
thought_signatures: None,
};
let part = ContentPart::ToolCall(tool_call);
content.push(part);
}
_ => (),
}
}
let reasoning_content = if !reasoning_content.is_empty() {
Some(reasoning_content.join("\n"))
} else {
None
};
Ok(ChatResponse {
content,
reasoning_content,
model_iden,
provider_model_iden,
usage,
captured_raw_body: None, })
}
fn to_chat_stream(
model_iden: ModelIden,
reqwest_builder: RequestBuilder,
options_set: ChatOptionsSet<'_, '_>,
) -> Result<ChatStreamResponse> {
let event_source = EventSourceStream::new(reqwest_builder);
let anthropic_stream = AnthropicStreamer::new(event_source, model_iden.clone(), options_set);
let chat_stream = ChatStream::from_inter_stream(anthropic_stream);
Ok(ChatStreamResponse {
model_iden,
stream: chat_stream,
})
}
fn to_embed_request_data(
_service_target: crate::ServiceTarget,
_embed_req: crate::embed::EmbedRequest,
_options_set: crate::embed::EmbedOptionsSet<'_, '_>,
) -> Result<crate::adapter::WebRequestData> {
Err(crate::Error::AdapterNotSupported {
adapter_kind: crate::adapter::AdapterKind::Anthropic,
feature: "embeddings".to_string(),
})
}
fn to_embed_response(
_model_iden: crate::ModelIden,
_web_response: crate::webc::WebResponse,
_options_set: crate::embed::EmbedOptionsSet<'_, '_>,
) -> Result<crate::embed::EmbedResponse> {
Err(crate::Error::AdapterNotSupported {
adapter_kind: crate::adapter::AdapterKind::Anthropic,
feature: "embeddings".to_string(),
})
}
}
impl AnthropicAdapter {
pub(super) fn into_usage(mut usage_value: Value) -> Usage {
let input_tokens: i32 = usage_value.x_take("input_tokens").ok().unwrap_or(0);
let cache_creation_input_tokens: i32 = usage_value.x_take("cache_creation_input_tokens").unwrap_or(0);
let cache_read_input_tokens: i32 = usage_value.x_take("cache_read_input_tokens").unwrap_or(0);
let completion_tokens: i32 = usage_value.x_take("output_tokens").ok().unwrap_or(0);
let prompt_tokens = input_tokens + cache_creation_input_tokens + cache_read_input_tokens;
let total_tokens = prompt_tokens + completion_tokens;
let prompt_tokens_details = if cache_creation_input_tokens > 0 || cache_read_input_tokens > 0 {
Some(PromptTokensDetails {
cache_creation_tokens: Some(cache_creation_input_tokens),
cached_tokens: Some(cache_read_input_tokens),
audio_tokens: None,
})
} else {
None
};
Usage {
prompt_tokens: Some(prompt_tokens),
prompt_tokens_details,
completion_tokens: Some(completion_tokens),
completion_tokens_details: None,
total_tokens: Some(total_tokens),
}
}
fn into_anthropic_request_parts(chat_req: ChatRequest) -> Result<AnthropicRequestParts> {
let mut messages: Vec<Value> = Vec::new();
let mut systems: Vec<(String, bool)> = Vec::new();
if let Some(system) = chat_req.system {
systems.push((system, false));
}
for msg in chat_req.messages {
let is_cache_control = msg.options.map(|o| o.cache_control.is_some()).unwrap_or(false);
match msg.role {
ChatRole::System => {
if let Some(system_text) = msg.content.joined_texts() {
systems.push((system_text, is_cache_control));
}
}
ChatRole::User => {
if msg.content.is_text_only() {
let text = msg.content.joined_texts().unwrap_or_else(String::new);
let content = apply_cache_control_to_text(is_cache_control, text);
messages.push(json!({"role": "user", "content": content}));
} else {
let mut values: Vec<Value> = Vec::new();
for part in msg.content {
match part {
ContentPart::Text(text) => {
values.push(json!({"type": "text", "text": text}));
}
ContentPart::Binary(binary) => {
let is_image = binary.is_image();
let Binary {
content_type, source, ..
} = binary;
if is_image {
match &source {
BinarySource::Url(_) => {
warn!(
"Anthropic doesn't support images from URL, need to handle it gracefully"
);
}
BinarySource::Base64(content) => {
values.push(json!({
"type": "image",
"source": {
"type": "base64",
"media_type": content_type,
"data": content,
}
}));
}
}
} else {
match &source {
BinarySource::Url(url) => {
values.push(json!({
"type": "document",
"source": {
"type": "url",
"url": url,
}
}));
}
BinarySource::Base64(b64) => {
values.push(json!({
"type": "document",
"source": {
"type": "base64",
"media_type": content_type,
"data": b64,
}
}));
}
}
}
}
ContentPart::ToolCall(_tc) => {}
ContentPart::ToolResponse(tool_response) => {
values.push(json!({
"type": "tool_result",
"content": tool_response.content,
"tool_use_id": tool_response.call_id,
}));
}
ContentPart::ThoughtSignature(_) => {}
}
}
let values = apply_cache_control_to_parts(is_cache_control, values);
messages.push(json!({"role": "user", "content": values}));
}
}
ChatRole::Assistant => {
let mut values: Vec<Value> = Vec::new();
let mut has_tool_use = false;
let mut has_text = false;
for part in msg.content {
match part {
ContentPart::Text(text) => {
has_text = true;
values.push(json!({"type": "text", "text": text}));
}
ContentPart::ToolCall(tool_call) => {
has_tool_use = true;
values.push(json!({
"type": "tool_use",
"id": tool_call.call_id,
"name": tool_call.fn_name,
"input": tool_call.fn_arguments,
}));
}
ContentPart::Binary(_) => {}
ContentPart::ToolResponse(_) => {}
ContentPart::ThoughtSignature(_) => {}
}
}
if !has_tool_use && has_text && !is_cache_control && values.len() == 1 {
let text = values
.first()
.and_then(|v| v.get("text"))
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
let content = apply_cache_control_to_text(false, text);
messages.push(json!({"role": "assistant", "content": content}));
} else {
let values = apply_cache_control_to_parts(is_cache_control, values);
messages.push(json!({"role": "assistant", "content": values}));
}
}
ChatRole::Tool => {
let mut values: Vec<Value> = Vec::new();
for part in msg.content {
if let ContentPart::ToolResponse(tool_response) = part {
values.push(json!({
"type": "tool_result",
"content": tool_response.content,
"tool_use_id": tool_response.call_id,
}));
}
}
if !values.is_empty() {
let values = apply_cache_control_to_parts(is_cache_control, values);
messages.push(json!({"role": "user", "content": values}));
}
}
}
}
let system = if !systems.is_empty() {
let mut last_cache_idx = -1;
for (idx, (_, is_cache_control)) in systems.iter().enumerate() {
if *is_cache_control {
last_cache_idx = idx as i32;
}
}
let system: Value = if last_cache_idx > 0 {
let mut parts: Vec<Value> = Vec::new();
for (idx, (content, _)) in systems.iter().enumerate() {
let idx = idx as i32;
if idx == last_cache_idx {
let part = json!({"type": "text", "text": content, "cache_control": {"type": "ephemeral"}});
parts.push(part);
} else {
let part = json!({"type": "text", "text": content});
parts.push(part);
}
}
json!(parts)
} else {
let content_buff = systems.iter().map(|(content, _)| content.as_str()).collect::<Vec<&str>>();
let content = content_buff.join("\n\n");
json!(content)
};
Some(system)
} else {
None
};
let tools = chat_req.tools.map(|tools| {
tools
.into_iter()
.map(|tool| {
let mut tool_value = json!({
"name": tool.name,
"input_schema": tool.schema,
});
if let Some(description) = tool.description {
let _ = tool_value.x_insert("description", description);
}
tool_value
})
.collect::<Vec<Value>>()
});
Ok(AnthropicRequestParts {
system,
messages,
tools,
})
}
}
fn apply_cache_control_to_text(is_cache_control: bool, content: String) -> Value {
if is_cache_control {
let value = json!({"type": "text", "text": content, "cache_control": {"type": "ephemeral"}});
json!(vec![value])
}
else {
json!(content)
}
}
fn apply_cache_control_to_parts(is_cache_control: bool, parts: Vec<Value>) -> Vec<Value> {
let mut parts = parts;
if is_cache_control && !parts.is_empty() {
let len = parts.len();
if let Some(last_value) = parts.get_mut(len - 1) {
let _ = last_value.x_insert("cache_control", json!( {"type": "ephemeral"}));
}
}
parts
}
struct AnthropicRequestParts {
system: Option<Value>,
messages: Vec<Value>,
tools: Option<Vec<Value>>,
}