use serde_json::{Value, json};
use super::{DEFAULT_MAX_TOKENS, is_hosted_tool_type, sanitize_tool_call_id};
use crate::error::{LiterLlmError, Result};
pub(super) fn transform_request(body: &mut Value) -> Result<()> {
crate::provider::validate_sampling_param_range(body, "temperature", "Anthropic", 0.0, 1.0)?;
let messages = take_messages(body)?;
let (system_blocks, non_system_messages) = split_system_messages(messages);
if !system_blocks.is_empty() {
body["system"] = json!(system_blocks);
}
let converted_messages: Vec<Value> = non_system_messages
.into_iter()
.map(convert_message_to_anthropic)
.collect();
let merged_messages = merge_consecutive_same_role(converted_messages);
body["messages"] = json!(merged_messages);
apply_max_tokens(body);
apply_stop_sequences(body);
apply_tool_choice(body);
convert_tools(body);
apply_reasoning_effort(body);
apply_response_format(body);
warn_on_dropped_params(body);
strip_unsupported_params(body);
Ok(())
}
fn take_messages(body: &mut Value) -> Result<Vec<Value>> {
let messages = body
.as_object_mut()
.and_then(|o| o.remove("messages"))
.and_then(|v| match v {
Value::Array(arr) => Some(arr),
_ => None,
})
.unwrap_or_default();
if messages.is_empty() {
return Err(LiterLlmError::BadRequest {
message: "messages array must not be empty".to_owned(),
status: 400,
});
}
Ok(messages)
}
fn split_system_messages(messages: Vec<Value>) -> (Vec<Value>, Vec<Value>) {
let mut system_blocks: Vec<Value> = Vec::new();
let mut non_system_messages: Vec<Value> = Vec::new();
for msg in messages {
let role = msg.get("role").and_then(|r| r.as_str()).unwrap_or("");
match role {
"system" | "developer" => match msg.get("content") {
Some(Value::String(s)) if !s.is_empty() => {
let mut block = json!({"type": "text", "text": s});
if let Some(cc) = msg.get("cache_control") {
block["cache_control"] = cc.clone();
}
system_blocks.push(block);
}
Some(Value::Array(parts)) => {
for part in parts {
system_blocks.push(part.clone());
}
}
_ => {}
},
_ => non_system_messages.push(msg),
}
}
(system_blocks, non_system_messages)
}
fn apply_max_tokens(body: &mut Value) {
if body.get("max_tokens").is_none() {
if let Some(mct) = body.get("max_completion_tokens").cloned() {
body["max_tokens"] = mct;
} else {
let catalog_limit = body
.get("model")
.and_then(Value::as_str)
.and_then(crate::cost::model_info)
.and_then(|info| info.max_output_tokens);
body["max_tokens"] = json!(catalog_limit.unwrap_or(DEFAULT_MAX_TOKENS));
}
}
body.as_object_mut().map(|o| o.remove("max_completion_tokens"));
}
fn apply_stop_sequences(body: &mut Value) {
if let Some(stop) = body.as_object_mut().and_then(|o| o.remove("stop")) {
let stop_sequences = match stop {
Value::String(s) => json!([s]),
arr @ Value::Array(_) => arr,
_ => json!([]),
};
body["stop_sequences"] = stop_sequences;
}
}
fn apply_tool_choice(body: &mut Value) {
if let Some(tool_choice) = body.as_object_mut().and_then(|o| o.remove("tool_choice")) {
let anthropic_tool_choice = convert_tool_choice(&tool_choice);
match anthropic_tool_choice {
Some(tc) => {
body["tool_choice"] = tc;
}
None => {
body.as_object_mut().map(|o| o.remove("tools"));
}
}
}
}
fn convert_tools(body: &mut Value) {
if let Some(tools) = body.as_object_mut().and_then(|o| o.remove("tools"))
&& let Some(tools_array) = tools.as_array()
{
let anthropic_tools: Vec<Value> = tools_array
.iter()
.map(|tool| {
let tool_type = tool.get("type").and_then(|t| t.as_str()).unwrap_or("");
if is_hosted_tool_type(tool_type) {
tool.clone()
} else {
convert_tool_to_anthropic(tool)
}
})
.collect();
body["tools"] = json!(anthropic_tools);
}
}
fn apply_reasoning_effort(body: &mut Value) {
let reasoning_effort = body
.as_object_mut()
.and_then(|o| o.remove("reasoning_effort"))
.and_then(|v| v.as_str().map(String::from))
.or_else(|| {
body.pointer("/extra_body/reasoning_effort")
.and_then(|v| v.as_str().map(String::from))
});
if let Some(effort) = reasoning_effort {
let budget_tokens: u64 = match effort.as_str() {
"minimal" => 1024,
"low" => 1024,
"medium" => 4096,
"high" => 16384,
"max" => 32768,
_ => 4096,
};
body["thinking"] = json!({
"type": "enabled",
"budget_tokens": budget_tokens
});
let min_max_tokens = budget_tokens + 1;
let current_max = body.get("max_tokens").and_then(|v| v.as_u64()).unwrap_or(0);
if current_max < min_max_tokens {
body["max_tokens"] = json!(min_max_tokens);
}
}
}
fn apply_response_format(body: &mut Value) {
let Some(response_format) = body.as_object_mut().and_then(|o| o.remove("response_format")) else {
return;
};
let rf_type = response_format.get("type").and_then(|t| t.as_str()).unwrap_or("");
let supports_structured_output = body
.get("model")
.and_then(Value::as_str)
.and_then(crate::cost::model_info)
.is_some_and(|info| info.supports_structured_output == Some(true));
if rf_type == "json_schema"
&& supports_structured_output
&& let Some(schema) = response_format.pointer("/json_schema/schema").cloned()
{
body["output_config"] = json!({
"format": {
"type": "json_schema",
"schema": schema
}
});
return;
}
if rf_type == "json_schema" && response_format.pointer("/json_schema/strict").and_then(Value::as_bool) == Some(true)
{
tracing::warn!(
"strict JSON schema output is unavailable for this Anthropic model; falling back to prompt-based formatting"
);
}
let instruction = match rf_type {
"json_object" => {
json!({"type": "text", "text": "Respond with valid JSON only. Do not include any text outside the JSON object."})
}
"json_schema" => {
let Some(schema_def) = response_format.get("json_schema") else {
return;
};
let schema_name = schema_def.get("name").and_then(|n| n.as_str()).unwrap_or("output");
let schema = schema_def.get("schema").cloned().unwrap_or(json!({}));
let schema_str = serde_json::to_string_pretty(&schema).unwrap_or_default();
let instruction_text = format!(
"Respond with valid JSON matching the following schema named '{schema_name}':\n```json\n{schema_str}\n```\nDo not include any text outside the JSON object."
);
json!({"type": "text", "text": instruction_text})
}
_ => return,
};
append_system_instruction(body, instruction);
}
fn append_system_instruction(body: &mut Value, instruction: Value) {
if let Some(system) = body.get_mut("system").and_then(|s| s.as_array_mut()) {
system.push(instruction);
} else {
body["system"] = json!([instruction]);
}
}
fn warn_on_dropped_params(body: &Value) {
let logprobs_requested = body.get("logprobs").and_then(Value::as_bool).unwrap_or(false);
let top_logprobs_requested = body.get("top_logprobs").is_some();
if logprobs_requested || top_logprobs_requested {
tracing::warn!(
"chat request set logprobs/top_logprobs, which Anthropic's Messages API does not \
support; the fields were dropped and the response will not include log \
probabilities"
);
}
if body.get("metadata").is_some() {
tracing::warn!(
"chat request set `metadata`, which was dropped: Anthropic's own `metadata` \
field only accepts `user_id` and is not compatible with arbitrary key/value tags"
);
}
if body.get("audio").is_some() {
tracing::warn!(
"chat request set `audio`, which was dropped: Anthropic's Messages API has no \
audio output support"
);
}
if body.get("web_search_options").is_some() {
tracing::warn!(
"chat request set `web_search_options`, which was dropped: Anthropic has no \
equivalent top-level field; use a `web_search_20250305` tool in `tools` instead"
);
}
let modalities_want_non_text = body
.get("modalities")
.and_then(Value::as_array)
.is_some_and(|modalities| modalities.iter().any(|m| m.as_str() != Some("text")));
if modalities_want_non_text {
tracing::warn!(
"chat request set `modalities` to include a non-text modality, which Anthropic's \
Messages API cannot produce; the field was dropped and the response will be \
text only"
);
}
if body.get("seed").is_some() {
tracing::warn!(
"chat request set `seed`, which Anthropic's Messages API has no equivalent for; \
the field was dropped and output will not be reproducible via a fixed seed"
);
}
}
fn strip_unsupported_params(body: &mut Value) {
if let Some(obj) = body.as_object_mut() {
for key in &[
"n",
"presence_penalty",
"frequency_penalty",
"logit_bias",
"stream_options",
"parallel_tool_calls",
"service_tier",
"user",
"reasoning_effort",
"extra_body",
"logprobs",
"top_logprobs",
"store",
"metadata",
"prediction",
"audio",
"web_search_options",
"modalities",
"seed",
] {
obj.remove(*key);
}
}
}
fn convert_image_url_to_anthropic_source(url: &str) -> Value {
if url.starts_with("data:")
&& let Some((header, data)) = url.split_once(',')
{
let media_type = header.trim_start_matches("data:").trim_end_matches(";base64");
return json!({
"type": "image",
"source": {
"type": "base64",
"media_type": media_type,
"data": data
}
});
}
json!({
"type": "image",
"source": {"type": "url", "url": url}
})
}
fn merge_consecutive_same_role(messages: Vec<Value>) -> Vec<Value> {
let mut merged: Vec<Value> = Vec::new();
for msg in messages {
let role = msg.get("role").and_then(|r| r.as_str()).unwrap_or("");
if let Some(last) = merged.last_mut()
&& last.get("role").and_then(|r| r.as_str()).unwrap_or("") == role
{
let incoming_content = content_as_blocks(msg.get("content"));
if let Some(Value::Array(existing)) = last.get_mut("content") {
existing.extend(incoming_content);
} else {
let mut combined = content_as_blocks(last.get("content"));
combined.extend(incoming_content);
last["content"] = json!(combined);
}
continue;
}
merged.push(msg);
}
merged
}
fn content_as_blocks(content: Option<&Value>) -> Vec<Value> {
match content {
Some(Value::Array(arr)) => arr.clone(),
Some(Value::String(s)) => vec![json!({"type": "text", "text": s})],
Some(other) => vec![json!({"type": "text", "text": other.to_string()})],
None => vec![],
}
}
fn convert_message_to_anthropic(msg: Value) -> Value {
let role = msg.get("role").and_then(|r| r.as_str()).unwrap_or("");
match role {
"user" => convert_user_message(&msg),
"assistant" => convert_assistant_message(&msg),
"tool" => convert_tool_message(&msg),
"function" => convert_function_message(&msg),
_ => msg,
}
}
fn convert_user_message(msg: &Value) -> Value {
let content = convert_user_content_to_anthropic(msg.get("content"));
let mut user_msg = json!({"role": "user", "content": content});
if let Some(cc) = msg.get("cache_control")
&& let Some(blocks) = user_msg.get_mut("content").and_then(|c| c.as_array_mut())
&& let Some(last) = blocks.last_mut()
{
last["cache_control"] = cc.clone();
}
user_msg
}
fn convert_assistant_message(msg: &Value) -> Value {
let mut blocks: Vec<Value> = Vec::new();
if let Some(text) = msg.get("content").and_then(|c| c.as_str())
&& !text.is_empty()
{
let mut block = json!({"type": "text", "text": text});
if let Some(cc) = msg.get("cache_control") {
block["cache_control"] = cc.clone();
}
blocks.push(block);
}
if let Some(tool_calls) = msg.get("tool_calls").and_then(|tc| tc.as_array()) {
for tc in tool_calls {
let id = tc.get("id").and_then(|v| v.as_str()).unwrap_or("");
let name = tc.pointer("/function/name").and_then(|v| v.as_str()).unwrap_or("");
let arguments_str = tc
.pointer("/function/arguments")
.and_then(|v| v.as_str())
.unwrap_or("{}");
let input: Value = serde_json::from_str(arguments_str).unwrap_or_else(|_| json!({}));
blocks.push(json!({
"type": "tool_use",
"id": id,
"name": name,
"input": input
}));
}
}
if blocks.is_empty() {
blocks.push(json!({"type": "text", "text": ""}));
}
json!({"role": "assistant", "content": blocks})
}
fn convert_tool_message(msg: &Value) -> Value {
let raw_id = msg.get("tool_call_id").and_then(|v| v.as_str()).unwrap_or("");
let tool_use_id = sanitize_tool_call_id(raw_id);
let result_content = convert_tool_result_content_to_anthropic(msg.get("content"));
let mut tool_result_block = json!({
"type": "tool_result",
"tool_use_id": tool_use_id,
"content": result_content
});
if let Some(cc) = msg.get("cache_control") {
tool_result_block["cache_control"] = cc.clone();
}
json!({
"role": "user",
"content": [tool_result_block]
})
}
fn convert_function_message(msg: &Value) -> Value {
let name = msg.get("name").and_then(|v| v.as_str()).unwrap_or("");
let sanitized_name = sanitize_tool_call_id(name);
let content_text = msg.get("content").and_then(|c| c.as_str()).unwrap_or("");
json!({
"role": "user",
"content": [{
"type": "tool_result",
"tool_use_id": sanitized_name,
"content": [{"type": "text", "text": content_text}]
}]
})
}
fn convert_user_content_to_anthropic(content: Option<&Value>) -> Value {
match content {
None => json!([]),
Some(Value::String(s)) => json!([{"type": "text", "text": s}]),
Some(Value::Array(parts)) => {
let blocks: Vec<Value> = parts
.iter()
.filter_map(|part| {
let part_type = part.get("type").and_then(|t| t.as_str())?;
match part_type {
"text" => {
let text = part.get("text").and_then(|t| t.as_str()).unwrap_or("");
let mut block = json!({"type": "text", "text": text});
if let Some(cc) = part.get("cache_control") {
block["cache_control"] = cc.clone();
}
Some(block)
}
"image_url" => {
let url = part.pointer("/image_url/url").and_then(|u| u.as_str())?;
let mut block = convert_image_url_to_anthropic_source(url);
if let Some(cc) = part.get("cache_control") {
block["cache_control"] = cc.clone();
}
Some(block)
}
"document" => {
let data = part.pointer("/document/data").and_then(|d| d.as_str())?;
let media_type = part
.pointer("/document/media_type")
.and_then(|m| m.as_str())
.unwrap_or("application/pdf");
let mut block = json!({
"type": "document",
"source": {
"type": "base64",
"media_type": media_type,
"data": data
}
});
if let Some(cc) = part.get("cache_control") {
block["cache_control"] = cc.clone();
}
Some(block)
}
_ => {
tracing::warn!(
part_type = part_type,
"unrecognized user content part type; falling back to text"
);
let text = part.get("text").and_then(|t| t.as_str()).unwrap_or("");
if text.is_empty() {
None
} else {
Some(json!({"type": "text", "text": text}))
}
}
}
})
.collect();
json!(blocks)
}
Some(other) => json!([{"type": "text", "text": other.to_string()}]),
}
}
fn convert_tool_result_content_to_anthropic(content: Option<&Value>) -> Value {
let Value::Array(blocks) = convert_user_content_to_anthropic(content) else {
return json!([{"type": "text", "text": ""}]);
};
let blocks: Vec<Value> = blocks
.into_iter()
.map(|block| match block.get("type").and_then(|t| t.as_str()) {
Some("text") | Some("image") => block,
other => {
tracing::warn!(
block_type = other.unwrap_or("unknown"),
"anthropic tool_result: unsupported content block degraded to text"
);
json!({"type": "text", "text": "[unsupported content in tool result]"})
}
})
.collect();
json!(blocks)
}
fn convert_tool_choice(tool_choice: &Value) -> Option<Value> {
match tool_choice {
Value::String(s) => match s.as_str() {
"none" => None,
"required" => Some(json!({"type": "any"})),
_ => Some(json!({"type": "auto"})),
},
Value::Object(_) => {
let name = tool_choice.pointer("/function/name").and_then(|v| v.as_str());
if let Some(name) = name {
Some(json!({"type": "tool", "name": name}))
} else {
Some(json!({"type": "auto"}))
}
}
_ => Some(json!({"type": "auto"})),
}
}
fn convert_tool_to_anthropic(tool: &Value) -> Value {
let function = tool.get("function");
let name = function.and_then(|f| f.get("name")).cloned().unwrap_or(json!(""));
let description = function.and_then(|f| f.get("description")).cloned();
let mut parameters = function
.and_then(|f| f.get("parameters"))
.cloned()
.unwrap_or(json!({"type": "object", "properties": {}}));
if parameters.get("type").and_then(|t| t.as_str()) != Some("object") {
parameters["type"] = json!("object");
}
let mut tool_def = json!({
"name": name,
"input_schema": parameters
});
if let Some(desc) = description {
tool_def["description"] = desc;
}
if let Some(cc) = tool.get("cache_control") {
tool_def["cache_control"] = cc.clone();
} else if let Some(cc) = function.and_then(|f| f.get("cache_control")) {
tool_def["cache_control"] = cc.clone();
}
tool_def
}