use super::AnthropicAdapter;
use super::ant_model::{AnthropicMaxTokens, AnthropicModel, AnthropicModelCapabilities};
use crate::Result;
use crate::adapter::adapters::anthropic::ant_reasoning::insert_anthropic_reasoning;
use crate::adapter::adapters::support::get_api_key;
use crate::adapter::{Adapter, AdapterKind, ServiceType, WebRequestData};
use crate::chat::{
Binary, BinarySource, CacheControl, CacheCreationDetails, ChatOptionsSet, ChatRequest, ChatResponse,
ChatResponseFormat, ChatRole, ContentPart, JsonSchemaDialect, MessageContent, PromptTokensDetails, ReasoningEffort,
StopReason, Tool, ToolCall, ToolChoice, ToolConfig, ToolName, Usage, sanitize_json_schema,
};
use crate::resolver::{AuthData, Endpoint};
use crate::webc::{WebClient, WebResponse};
use crate::{Headers, ModelIden};
use serde_json::{Map, Value, json};
use tracing::warn;
use value_ext::JsonValueExt;
const ANTHROPIC_VERSION: &str = "2023-06-01";
pub(in crate::adapter::adapters) const MAX_TOKENS_64K: u32 = 64000; pub(in crate::adapter::adapters) const MAX_TOKENS_128K: u32 = 128000; pub(in crate::adapter::adapters) const MAX_TOKENS_32K: u32 = 32000; pub(in crate::adapter::adapters) const MAX_TOKENS_8K: u32 = 8192; pub(in crate::adapter::adapters) const MAX_TOKENS_4K: u32 = 4096;
impl AnthropicAdapter {
pub(in crate::adapter::adapters) fn resolve_max_tokens(model_name: &str, options_set: &ChatOptionsSet) -> u32 {
let capabilities = AnthropicModel::parse(model_name).capabilities();
Self::resolve_max_tokens_for_capabilities(&capabilities, options_set)
}
fn resolve_max_tokens_for_capabilities(
capabilities: &AnthropicModelCapabilities,
options_set: &ChatOptionsSet,
) -> u32 {
options_set.max_tokens().unwrap_or({
match capabilities.max_tokens {
AnthropicMaxTokens::Tokens128K => MAX_TOKENS_128K,
AnthropicMaxTokens::Tokens64K => MAX_TOKENS_64K,
AnthropicMaxTokens::Tokens32K => MAX_TOKENS_32K,
AnthropicMaxTokens::Tokens8K => MAX_TOKENS_8K,
AnthropicMaxTokens::Tokens4K => MAX_TOKENS_4K,
}
})
}
pub(in crate::adapter::adapters) 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 cache_creation_details = usage_value.get("cache_creation").and_then(parse_cache_creation_details);
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 || cache_creation_details.is_some() {
Some(PromptTokensDetails {
cache_creation_tokens: Some(cache_creation_input_tokens),
cache_creation_details,
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),
}
}
pub(in crate::adapter::adapters) fn into_anthropic_request_parts(
mut chat_req: ChatRequest,
request_cache_control: Option<CacheControl>,
) -> Result<AnthropicRequestParts> {
let mut messages: Vec<Value> = Vec::new();
let mut systems: Vec<(String, Option<CacheControl>)> = Vec::new();
let mut seen_5m_cache = false;
if let Some(system) = chat_req.system {
systems.push((system, None));
}
let mut has_msg_cache = false;
for msg in chat_req.messages {
let cache_control = msg.options.and_then(|o| o.cache_control);
if cache_control.is_some() {
has_msg_cache = true;
}
if let Some(ref cc) = cache_control {
match cc {
CacheControl::Memory | CacheControl::Ephemeral | CacheControl::Ephemeral5m => {
seen_5m_cache = true;
}
CacheControl::Ephemeral1h | CacheControl::Ephemeral24h => {
if seen_5m_cache {
warn!(
"Anthropic cache TTL ordering violation: a longer-TTL entry (Ephemeral1h/Ephemeral24h) appears after Ephemeral/Ephemeral5m. \
Longer-TTL cache entries must appear before shorter (5-minute) entries. \
See: https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching#mixing-different-ttls"
);
}
}
}
}
match msg.role {
ChatRole::System => {
if let Some(system_text) = msg.content.joined_texts() {
systems.push((system_text, 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(cache_control.as_ref(), 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(_) => {}
ContentPart::ReasoningContent(_) => {}
ContentPart::Custom(custom_part) => values.push(custom_part.data),
}
}
let values = apply_cache_control_to_parts(cache_control.as_ref(), 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;
let input = if tool_call.fn_arguments.is_null() {
Value::Object(Map::new())
} else {
tool_call.fn_arguments
};
values.push(json!({
"type": "tool_use",
"id": tool_call.call_id,
"name": tool_call.fn_name,
"input": input,
}));
}
ContentPart::Binary(_) => {}
ContentPart::ToolResponse(_) => {}
ContentPart::ThoughtSignature(_) => {}
ContentPart::ReasoningContent(_) => {}
ContentPart::Custom(custom_part) => values.push(custom_part.data),
}
}
if !has_tool_use && has_text && cache_control.is_none() && 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(None, text);
messages.push(json!({"role": "assistant", "content": content}));
} else {
let values = apply_cache_control_to_parts(cache_control.as_ref(), values);
messages.push(json!({"role": "assistant", "content": values}));
}
}
ChatRole::Tool => {
let mut values: Vec<Value> = Vec::new();
for part in msg.content {
match part {
ContentPart::ToolResponse(tool_response) => {
values.push(json!({
"type": "tool_result",
"content": tool_response.content,
"tool_use_id": tool_response.call_id,
}));
}
ContentPart::Custom(custom_part) => values.push(custom_part.data),
_ => {}
}
}
if !values.is_empty() {
let values = apply_cache_control_to_parts(cache_control.as_ref(), values);
messages.push(json!({"role": "user", "content": values}));
}
}
}
}
if let Some(req_cc) = request_cache_control {
let has_tool_cache = chat_req
.tools
.as_ref()
.map(|tools| tools.iter().any(|t| t.cache_control.is_some()))
.unwrap_or(false);
if !has_msg_cache && !has_tool_cache {
if let Some(last_system) = systems.last_mut() {
last_system.1 = Some(req_cc);
} else if let Some(last_tool) = chat_req.tools.as_mut().and_then(|tools| tools.last_mut()) {
last_tool.cache_control = Some(req_cc);
}
}
}
let system = if !systems.is_empty() {
let has_any_cache = systems.iter().any(|(_, cc)| cc.is_some());
let system: Value = if has_any_cache {
let parts: Vec<Value> = systems
.iter()
.map(|(content, cc)| {
if let Some(cc) = cc {
json!({"type": "text", "text": content, "cache_control": cache_control_to_json(cc)})
} else {
json!({"type": "text", "text": content})
}
})
.collect();
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: Option<Vec<Value>> = chat_req
.tools
.map(|tools| {
tools
.into_iter()
.map(Self::tool_to_anthropic_tool)
.collect::<Result<Vec<Value>>>()
})
.transpose()?;
Ok(AnthropicRequestParts {
system,
messages,
tools,
})
}
pub(in crate::adapter::adapters) fn build_web_request_data(
endpoint: Endpoint,
auth: AuthData,
model: ModelIden,
service_type: ServiceType,
chat_req: ChatRequest,
options_set: ChatOptionsSet<'_, '_>,
) -> Result<WebRequestData> {
let response_schema = options_set.response_format().and_then(|format| match format {
ChatResponseFormat::JsonSpec(spec) => Some(sanitize_json_schema(
&spec.schema,
JsonSchemaDialect::AnthropicStructured,
)),
ChatResponseFormat::JsonMode => None,
});
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-version".to_string(), ANTHROPIC_VERSION.to_string()),
]);
let AnthropicRequestParts {
system,
messages,
tools,
} = Self::into_anthropic_request_parts(chat_req, options_set.cache_control().cloned())?;
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) => {
let (reasoning, model_name) = ReasoningEffort::from_model_name(model);
(model_name, reasoning)
}
(model, Some(effort)) => (model, Some(effort.clone())),
};
let anthropic_model = AnthropicModel::parse(model_name);
let capabilities = anthropic_model.capabilities();
let stream = matches!(service_type, ServiceType::ChatStream);
let mut payload = json!({
"model": anthropic_model.normalized_name.to_string(),
"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(tool_choice) = anthropic_tool_choice(options_set.tool_choice()) {
payload.x_insert("tool_choice", tool_choice)?;
}
payload.x_insert("messages", messages)?;
let mut output_config: Map<String, Value> = Map::new();
if let Some(computed_reasoning_effort) = computed_reasoning_effort {
let capture_reasoning_content = options_set.capture_reasoning_content().unwrap_or_default();
insert_anthropic_reasoning(
&mut payload,
&mut output_config,
&capabilities,
&computed_reasoning_effort,
capture_reasoning_content,
)?;
}
if let Some(schema) = response_schema {
output_config.insert(
"format".to_string(),
json!({
"type": "json_schema",
"schema": schema,
}),
);
}
if !output_config.is_empty() {
payload.x_insert("output_config", Value::Object(output_config))?;
}
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 = Self::resolve_max_tokens_for_capabilities(&capabilities, &options_set);
payload.x_insert("max_tokens", max_tokens)?;
if let Some(top_p) = options_set.top_p() {
payload.x_insert("top_p", top_p)?;
}
if let Some(extra_body) = options_set.extra_body() {
payload.x_merge(extra_body.clone())?;
}
Ok(WebRequestData { url, headers, payload })
}
pub(in crate::adapter::adapters) fn build_chat_response(
model_iden: ModelIden,
web_response: WebResponse,
) -> 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 stop_reason = body
.x_take::<Option<String>>("stop_reason")
.ok()
.flatten()
.map(StopReason::from);
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: String = item.x_take("type")?;
match typ.as_ref() {
"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);
}
other_typ => {
item.x_insert("type", other_typ)?;
content.push(ContentPart::from_custom(item, Some(model_iden.clone())))
}
}
}
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,
stop_reason,
usage,
captured_raw_body: None, response_id: None,
})
}
pub(in crate::adapter::adapters) async fn list_model_names_for_end_target(
kind: AdapterKind,
endpoint: Endpoint,
auth: AuthData,
web_client: &WebClient,
) -> Result<Vec<String>> {
let base_url = endpoint.base_url();
let url = format!("{base_url}models");
let api_key = auth.single_key_value().ok();
let headers = api_key
.map(|api_key| {
Headers::from(vec![
("x-api-key".to_string(), api_key),
("anthropic-version".to_string(), ANTHROPIC_VERSION.to_string()),
])
})
.unwrap_or_default();
let mut res = web_client
.do_get(&url, &headers)
.await
.map_err(|webc_error| crate::Error::WebAdapterCall {
adapter_kind: kind,
webc_error,
})?;
let mut models: Vec<String> = Vec::new();
if let Value::Array(models_value) = res.body.x_take("data")? {
for mut model in models_value {
let model_name: String = model.x_take("id")?;
models.push(model_name);
}
}
Ok(models)
}
fn tool_to_anthropic_tool(tool: Tool) -> Result<Value> {
let Tool {
name,
description,
schema,
config,
cache_control,
eager_input_streaming,
strict,
..
} = tool;
let name = match name {
ToolName::WebSearch => "web_search".to_string(),
ToolName::Custom(name) => name,
};
let mut tool_value = json!({"name": name});
#[allow(clippy::single_match)] match name.as_str() {
"web_search" => {
tool_value.x_insert("type", "web_search_20250305")?;
}
_ => (),
}
if tool_value.get("type").is_some() {
if let Some(config) = config {
match config {
ToolConfig::WebSearch(config) => {
if let Some(max_uses) = config.max_uses {
let _ = tool_value.x_insert("max_uses", max_uses);
}
if let Some(allowed_domains) = config.allowed_domains {
let _ = tool_value.x_insert("allowed_domains", allowed_domains);
}
if let Some(blocked_domains) = config.blocked_domains {
let _ = tool_value.x_insert("blocked_domains", blocked_domains);
}
}
ToolConfig::Custom(config) => {
tool_value.x_merge(config)?;
}
}
}
} else {
let schema = if strict == Some(true) {
schema.map(|schema| sanitize_json_schema(&schema, JsonSchemaDialect::AnthropicStructured))
} else {
schema
};
tool_value.x_insert("input_schema", schema)?;
if let Some(strict) = strict {
tool_value.x_insert("strict", strict)?;
}
if let Some(description) = description {
let _ = tool_value.x_insert("description", description);
}
if eager_input_streaming == Some(true) {
let _ = tool_value.x_insert("eager_input_streaming", true);
}
}
if let Some(cc) = cache_control {
let _ = tool_value.x_insert("cache_control", cache_control_to_json(&cc));
}
Ok(tool_value)
}
}
pub(in crate::adapter) struct AnthropicRequestParts {
pub system: Option<Value>,
pub messages: Vec<Value>,
pub tools: Option<Vec<Value>>,
}
fn cache_control_to_json(cache_control: &CacheControl) -> Value {
match cache_control {
CacheControl::Ephemeral => {
json!({"type": "ephemeral"})
}
CacheControl::Memory => {
json!({"type": "ephemeral"})
}
CacheControl::Ephemeral5m => {
json!({"type": "ephemeral", "ttl": "5m"})
}
CacheControl::Ephemeral1h => {
json!({"type": "ephemeral", "ttl": "1h"})
}
CacheControl::Ephemeral24h => {
json!({"type": "ephemeral", "ttl": "1h"})
}
}
}
pub(super) fn parse_cache_creation_details(cache_creation: &Value) -> Option<CacheCreationDetails> {
let ephemeral_5m_tokens = cache_creation
.get("ephemeral_5m_input_tokens")
.and_then(|v| v.as_i64())
.map(|v| v as i32);
let ephemeral_1h_tokens = cache_creation
.get("ephemeral_1h_input_tokens")
.and_then(|v| v.as_i64())
.map(|v| v as i32);
if ephemeral_5m_tokens.is_some() || ephemeral_1h_tokens.is_some() {
Some(CacheCreationDetails {
ephemeral_5m_tokens,
ephemeral_1h_tokens,
})
} else {
None
}
}
fn apply_cache_control_to_text(cache_control: Option<&CacheControl>, content: String) -> Value {
if let Some(cc) = cache_control {
let value = json!({"type": "text", "text": content, "cache_control": cache_control_to_json(cc)});
json!(vec![value])
}
else {
json!(content)
}
}
fn apply_cache_control_to_parts(cache_control: Option<&CacheControl>, parts: Vec<Value>) -> Vec<Value> {
let mut parts = parts;
if let Some(cc) = 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", cache_control_to_json(cc));
}
}
parts
}
fn anthropic_tool_choice(tool_choice: Option<&ToolChoice>) -> Option<Value> {
match tool_choice? {
ToolChoice::Auto => Some(json!({"type": "auto"})),
ToolChoice::None => Some(json!({"type": "none"})),
ToolChoice::Required => Some(json!({"type": "any"})),
ToolChoice::Tool { name } => Some(json!({
"type": "tool",
"name": name
})),
}
}
#[cfg(test)]
#[path = "adapter_shared_tests.rs"]
mod tests;