use crate::anthropic;
use crate::mapping::tools_map;
use crate::openai;
use crate::util;
pub fn anthropic_to_openai_request(
req: &anthropic::MessageCreateRequest,
) -> openai::ChatCompletionRequest {
let mut messages = Vec::new();
if let Some(ref system) = req.system {
let text = super::extract_system_text(system);
messages.push(openai::ChatMessage {
role: openai::ChatRole::System,
content: Some(openai::ChatContent::Text(text)),
name: None,
tool_calls: None,
tool_call_id: None,
refusal: None,
reasoning_content: None,
});
}
for msg in &req.messages {
convert_anthropic_message(msg, &mut messages);
}
let tools = req
.tools
.as_ref()
.map(|t| tools_map::anthropic_tools_to_openai(t));
let tool_choice = req
.tool_choice
.as_ref()
.map(tools_map::anthropic_tool_choice_to_openai);
let parallel_tool_calls = match req.tool_choice.as_ref() {
Some(anthropic::ToolChoice::Auto {
disable_parallel_tool_use: Some(true),
})
| Some(anthropic::ToolChoice::Any {
disable_parallel_tool_use: Some(true),
}) => Some(false),
_ => None,
};
let user = req.metadata.as_ref().and_then(|m| m.user_id.clone());
if req.top_k.is_some() {
tracing::warn!("top_k parameter dropped: no OpenAI equivalent");
}
let reasoning_effort = match &req.thinking {
Some(crate::anthropic::ThinkingConfig::Enabled { budget_tokens }) => {
let effort = if *budget_tokens < 4_000 {
"low"
} else if *budget_tokens < 16_000 {
"medium"
} else {
"high"
};
tracing::info!(
budget_tokens,
reasoning_effort = effort,
"thinking config mapped to reasoning_effort"
);
Some(effort)
}
_ => None,
};
let stop = req.stop_sequences.as_ref().and_then(|seqs| {
if seqs.is_empty() {
return None;
}
if seqs.len() > 4 {
tracing::warn!(
count = seqs.len(),
"stop_sequences truncated from {} to 4 (OpenAI limit)",
seqs.len()
);
}
let capped: Vec<String> = seqs.iter().take(4).cloned().collect();
Some(if capped.len() == 1 {
openai::Stop::Single(capped.into_iter().next().unwrap())
} else {
openai::Stop::Multiple(capped)
})
});
let mut oai_req = openai::ChatCompletionRequest {
model: req.model.clone(),
messages,
max_tokens: Some(req.max_tokens),
max_completion_tokens: Some(req.max_tokens),
temperature: req.temperature.map(|t| t.clamp(0.0, 1.0)),
top_p: req.top_p,
stop,
tools,
tool_choice,
stream: req.stream,
stream_options: if req.stream == Some(true) {
Some(openai::StreamOptions {
include_usage: true,
})
} else {
None
},
presence_penalty: None,
frequency_penalty: None,
response_format: None,
user,
parallel_tool_calls,
extra: req.extra.clone(),
};
if let Some(effort) = reasoning_effort {
oai_req
.extra
.entry("reasoning_effort")
.or_insert_with(|| serde_json::Value::String(effort.to_owned()));
}
if let Some(n_val) = oai_req.extra.remove("n") {
if n_val != serde_json::Value::Number(1.into()) {
tracing::warn!(n = %n_val, "n parameter stripped: Anthropic API returns single completions only");
}
}
if is_o_series_model(&oai_req.model) {
oai_req.max_tokens = None;
oai_req.temperature = None;
oai_req.top_p = None;
for msg in &mut oai_req.messages {
if msg.role == openai::ChatRole::System {
msg.role = openai::ChatRole::Developer;
}
}
}
if let Some(forced_name) = req.tool_choice.as_ref().and_then(extract_forced_tool_name) {
if let Some(ref mut tools) = oai_req.tools {
apply_strict_mode_to_tool(tools, &forced_name);
}
}
oai_req
}
fn extract_forced_tool_name(tc: &anthropic::ToolChoice) -> Option<String> {
match tc {
anthropic::ToolChoice::Tool { name } => Some(name.clone()),
_ => None,
}
}
fn apply_strict_mode_to_tool(tools: &mut [openai::ChatTool], forced_name: &str) {
for tool in tools.iter_mut() {
if tool.function.name == forced_name {
tool.function.strict = Some(true);
if let Some(params) = tool.function.parameters.take() {
tool.function.parameters = Some(tools_map::normalize_schema_for_strict(params));
}
break;
}
}
}
pub(crate) fn is_o_series_model(model: &str) -> bool {
let bytes = model.as_bytes();
if bytes.is_empty() || !bytes[0].eq_ignore_ascii_case(&b'o') {
return false;
}
if bytes.len() < 2 || !bytes[1].is_ascii_digit() {
return false;
}
let after_digits = bytes[1..]
.iter()
.position(|b| !b.is_ascii_digit())
.map(|p| 1 + p)
.unwrap_or(bytes.len());
after_digits == bytes.len() || bytes[after_digits] == b'-'
}
fn convert_anthropic_message(msg: &anthropic::InputMessage, out: &mut Vec<openai::ChatMessage>) {
let role = match msg.role {
anthropic::Role::User => openai::ChatRole::User,
anthropic::Role::Assistant => openai::ChatRole::Assistant,
};
match &msg.content {
anthropic::Content::Text(text) => {
out.push(openai::ChatMessage {
role,
content: Some(openai::ChatContent::Text(text.clone())),
name: None,
tool_calls: None,
tool_call_id: None,
refusal: None,
reasoning_content: None,
});
}
anthropic::Content::Blocks(blocks) => {
if msg.role == anthropic::Role::Assistant {
convert_assistant_blocks(blocks, out);
} else {
convert_user_blocks(blocks, out);
}
}
}
}
fn convert_assistant_blocks(
blocks: &[anthropic::ContentBlock],
out: &mut Vec<openai::ChatMessage>,
) {
let mut text_parts = Vec::new();
let mut tool_calls = Vec::new();
let mut thinking_parts = Vec::new();
for block in blocks {
match block {
anthropic::ContentBlock::Text { text } => {
text_parts.push(text.clone());
}
anthropic::ContentBlock::ToolUse { id, name, input } => {
tool_calls.push(openai::ToolCall {
id: id.clone(),
call_type: "function".to_string(),
function: openai::FunctionCall {
name: name.clone(),
arguments: util::json::value_to_json_string(input),
},
});
}
anthropic::ContentBlock::Thinking { thinking, .. } => {
thinking_parts.push(thinking.clone());
}
_ => {}
}
}
let content = if text_parts.is_empty() {
None
} else {
Some(openai::ChatContent::Text(text_parts.join("")))
};
let reasoning_content = if thinking_parts.is_empty() {
None
} else {
Some(thinking_parts.join(""))
};
out.push(openai::ChatMessage {
role: openai::ChatRole::Assistant,
content,
name: None,
tool_calls: if tool_calls.is_empty() {
None
} else {
Some(tool_calls)
},
tool_call_id: None,
refusal: None,
reasoning_content,
});
}
fn image_source_to_url(source: &anthropic::messages::ImageSource) -> Option<String> {
if let Some(ref url) = source.url {
Some(url.clone())
} else if let Some(ref data) = source.data {
let mt = source.media_type.as_deref().unwrap_or("image/png");
Some(format!("data:{};base64,{}", mt, data))
} else {
None
}
}
fn simplify_content_parts(mut parts: Vec<openai::ChatContentPart>) -> openai::ChatContent {
if parts.len() == 1 {
match parts.remove(0) {
openai::ChatContentPart::Text { text } => openai::ChatContent::Text(text),
other => openai::ChatContent::Parts(vec![other]),
}
} else {
openai::ChatContent::Parts(parts)
}
}
fn convert_user_blocks(blocks: &[anthropic::ContentBlock], out: &mut Vec<openai::ChatMessage>) {
let mut content_parts: Vec<openai::ChatContentPart> = Vec::new();
let mut tool_results: Vec<(String, Vec<openai::ChatContentPart>)> = Vec::new();
for block in blocks {
match block {
anthropic::ContentBlock::Text { text } => {
content_parts.push(openai::ChatContentPart::Text { text: text.clone() });
}
anthropic::ContentBlock::Image { source } => {
if let Some(url) = image_source_to_url(source) {
content_parts.push(openai::ChatContentPart::ImageUrl {
image_url: openai::chat_completions::ImageUrl { url, detail: None },
});
}
}
anthropic::ContentBlock::ToolResult {
tool_use_id,
content,
is_error,
} => {
let mut parts: Vec<openai::ChatContentPart> = Vec::new();
match content {
Some(anthropic::messages::ToolResultContent::Text(s)) => {
parts.push(openai::ChatContentPart::Text { text: s.clone() });
}
Some(anthropic::messages::ToolResultContent::Blocks(inner)) => {
for b in inner {
match b {
anthropic::ContentBlock::Text { text } => {
parts
.push(openai::ChatContentPart::Text { text: text.clone() });
}
anthropic::ContentBlock::Image { source } => {
if let Some(url) = image_source_to_url(source) {
parts.push(openai::ChatContentPart::ImageUrl {
image_url: openai::chat_completions::ImageUrl {
url,
detail: None,
},
});
}
}
_ => {}
}
}
}
None => {}
};
if *is_error == Some(true) {
if let Some(openai::ChatContentPart::Text { ref mut text }) = parts
.iter_mut()
.find(|p| matches!(p, openai::ChatContentPart::Text { .. }))
{
*text = format!("Error: {}", text);
} else {
parts.insert(
0,
openai::ChatContentPart::Text {
text: "Error".to_string(),
},
);
}
}
if parts.is_empty() {
parts.push(openai::ChatContentPart::Text {
text: String::new(),
});
}
tool_results.push((tool_use_id.clone(), parts));
}
anthropic::ContentBlock::Document { source, title } => {
let label = title.as_deref().unwrap_or("document");
tracing::warn!(
label = label,
"document block degraded to text note: no OpenAI Chat Completions equivalent"
);
let note = format!(
"[Attached {}: {} ({} bytes base64)]",
label,
source.media_type,
source.data.len()
);
content_parts.push(openai::ChatContentPart::Text { text: note });
}
anthropic::ContentBlock::ToolUse { .. }
| anthropic::ContentBlock::Thinking { .. }
| anthropic::ContentBlock::RedactedThinking { .. } => {}
_ => {}
}
}
for (tool_call_id, parts) in tool_results {
let content = Some(simplify_content_parts(parts));
out.push(openai::ChatMessage {
role: openai::ChatRole::Tool,
content,
name: None,
tool_calls: None,
tool_call_id: Some(tool_call_id),
refusal: None,
reasoning_content: None,
});
}
if !content_parts.is_empty() {
let content = Some(simplify_content_parts(content_parts));
out.push(openai::ChatMessage {
role: openai::ChatRole::User,
content,
name: None,
tool_calls: None,
tool_call_id: None,
refusal: None,
reasoning_content: None,
});
}
}