use crate::proxy::error::ProxyError;
use serde_json::{json, Value};
use std::borrow::Cow;
const ANTHROPIC_BILLING_HEADER_PREFIX: &str = "x-anthropic-billing-header:";
pub fn is_openai_o_series(model: &str) -> bool {
model.len() > 1
&& model.starts_with('o')
&& model.as_bytes().get(1).is_some_and(|b| b.is_ascii_digit())
}
pub fn supports_reasoning_effort(model: &str) -> bool {
is_openai_o_series(model)
|| model
.to_lowercase()
.strip_prefix("gpt-")
.and_then(|rest| rest.chars().next())
.is_some_and(|c| c.is_ascii_digit() && c >= '5')
}
pub fn resolve_reasoning_effort(body: &Value) -> Option<&'static str> {
if let Some(effort) = body
.pointer("/output_config/effort")
.and_then(|value| value.as_str())
{
return match effort {
"low" => Some("low"),
"medium" => Some("medium"),
"high" => Some("high"),
"max" => Some("xhigh"),
_ => None,
};
}
let thinking = body.get("thinking")?;
match thinking.get("type").and_then(|value| value.as_str()) {
Some("adaptive") => Some("xhigh"),
Some("enabled") => {
let budget = thinking
.get("budget_tokens")
.and_then(|value| value.as_u64());
match budget {
Some(budget) if budget < 4_000 => Some("low"),
Some(budget) if budget < 16_000 => Some("medium"),
Some(_) | None => Some("high"),
}
}
_ => None,
}
}
pub fn sanitize_system_text(text: &str) -> Option<Cow<'_, str>> {
let mut sanitized = String::new();
let mut removed = false;
for segment in text.split_inclusive('\n') {
let line = segment.strip_suffix('\n').unwrap_or(segment);
if line
.trim_start()
.starts_with(ANTHROPIC_BILLING_HEADER_PREFIX)
{
removed = true;
continue;
}
sanitized.push_str(segment);
}
if !removed {
return Some(Cow::Borrowed(text));
}
if sanitized.is_empty() {
None
} else {
Some(Cow::Owned(sanitized))
}
}
pub fn anthropic_to_openai(body: Value, cache_key: Option<&str>) -> Result<Value, ProxyError> {
anthropic_to_openai_with_reasoning_content(body, cache_key, false)
}
pub fn anthropic_to_openai_with_reasoning_content(
body: Value,
cache_key: Option<&str>,
preserve_reasoning_content: bool,
) -> Result<Value, ProxyError> {
let mut result = json!({});
if let Some(model) = body.get("model").and_then(|m| m.as_str()) {
result["model"] = json!(model);
}
let mut messages = Vec::new();
if let Some(system) = body.get("system") {
if let Some(text) = system.as_str() {
if let Some(text) = sanitize_system_text(text) {
messages.push(json!({"role": "system", "content": text}));
}
} else if let Some(arr) = system.as_array() {
for msg in arr {
if let Some(text) = msg.get("text").and_then(|t| t.as_str()) {
let Some(text) = sanitize_system_text(text) else {
continue;
};
let mut system_message = json!({"role": "system", "content": text});
if let Some(cache_control) = msg.get("cache_control") {
system_message["cache_control"] = cache_control.clone();
}
messages.push(system_message);
}
}
}
}
if let Some(msgs) = body.get("messages").and_then(|m| m.as_array()) {
for msg in msgs {
let role = msg.get("role").and_then(|r| r.as_str()).unwrap_or("user");
let content = msg.get("content");
messages.extend(convert_message_to_openai(
role,
content,
preserve_reasoning_content,
)?);
}
}
normalize_openai_system_messages(&mut messages);
result["messages"] = json!(messages);
let model = body.get("model").and_then(|m| m.as_str()).unwrap_or("");
if let Some(v) = body.get("max_tokens") {
if is_openai_o_series(model) {
result["max_completion_tokens"] = v.clone();
} else {
result["max_tokens"] = v.clone();
}
}
if let Some(v) = body.get("temperature") {
result["temperature"] = v.clone();
}
if let Some(v) = body.get("top_p") {
result["top_p"] = v.clone();
}
if let Some(v) = body.get("stop_sequences") {
result["stop"] = v.clone();
}
if let Some(v) = body.get("stream") {
result["stream"] = v.clone();
}
if supports_reasoning_effort(model) {
if let Some(effort) = resolve_reasoning_effort(&body) {
result["reasoning_effort"] = json!(effort);
}
}
if let Some(tools) = body.get("tools").and_then(|t| t.as_array()) {
let openai_tools: Vec<Value> = tools
.iter()
.filter(|t| t.get("type").and_then(|v| v.as_str()) != Some("BatchTool"))
.map(|t| {
let mut tool = json!({
"type": "function",
"function": {
"name": t.get("name").and_then(|n| n.as_str()).unwrap_or(""),
"description": t.get("description"),
"parameters": clean_schema(t.get("input_schema").cloned().unwrap_or(json!({})))
}
});
if let Some(cache_control) = t.get("cache_control") {
tool["cache_control"] = cache_control.clone();
}
tool
})
.collect();
if !openai_tools.is_empty() {
result["tools"] = json!(openai_tools);
}
}
if let Some(v) = body.get("tool_choice") {
result["tool_choice"] = v.clone();
}
if let Some(key) = cache_key {
result["prompt_cache_key"] = json!(key);
}
Ok(result)
}
fn normalize_openai_system_messages(messages: &mut Vec<Value>) {
let system_count = messages
.iter()
.filter(|message| message.get("role").and_then(|value| value.as_str()) == Some("system"))
.count();
if system_count == 0 {
return;
}
if system_count == 1 {
if let Some(index) = messages.iter().position(|message| {
message.get("role").and_then(|value| value.as_str()) == Some("system")
}) {
if index > 0 {
let message = messages.remove(index);
messages.insert(0, message);
}
}
return;
}
let mut parts = Vec::new();
messages.retain(|message| {
if message.get("role").and_then(|value| value.as_str()) != Some("system") {
return true;
}
match message.get("content") {
Some(Value::String(text)) if !text.is_empty() => parts.push(text.clone()),
Some(Value::Array(content_parts)) => {
let text = content_parts
.iter()
.filter_map(|part| part.get("text").and_then(|value| value.as_str()))
.collect::<Vec<_>>()
.join("\n");
if !text.is_empty() {
parts.push(text);
}
}
_ => {}
}
false
});
if !parts.is_empty() {
messages.insert(0, json!({"role": "system", "content": parts.join("\n")}));
}
}
fn convert_message_to_openai(
role: &str,
content: Option<&Value>,
preserve_reasoning_content: bool,
) -> Result<Vec<Value>, ProxyError> {
let mut result = Vec::new();
let content = match content {
Some(c) => c,
None => {
result.push(json!({"role": role, "content": null}));
return Ok(result);
}
};
if let Some(text) = content.as_str() {
result.push(json!({"role": role, "content": text}));
return Ok(result);
}
if let Some(blocks) = content.as_array() {
let mut content_parts = Vec::new();
let mut tool_calls = Vec::new();
let mut reasoning_parts = Vec::new();
for block in blocks {
let block_type = block.get("type").and_then(|t| t.as_str()).unwrap_or("");
match block_type {
"text" => {
if let Some(text) = block.get("text").and_then(|t| t.as_str()) {
let mut part = json!({"type": "text", "text": text});
if let Some(cache_control) = block.get("cache_control") {
part["cache_control"] = cache_control.clone();
}
content_parts.push(part);
}
}
"image" => {
if let Some(source) = block.get("source") {
let media_type = source
.get("media_type")
.and_then(|m| m.as_str())
.unwrap_or("image/png");
let data = source.get("data").and_then(|d| d.as_str()).unwrap_or("");
content_parts.push(json!({
"type": "image_url",
"image_url": {"url": format!("data:{};base64,{}", media_type, data)}
}));
}
}
"tool_use" => {
let id = block.get("id").and_then(|i| i.as_str()).unwrap_or("");
let name = block.get("name").and_then(|n| n.as_str()).unwrap_or("");
let input = block.get("input").cloned().unwrap_or(json!({}));
tool_calls.push(json!({
"id": id,
"type": "function",
"function": {
"name": name,
"arguments": serde_json::to_string(&input).unwrap_or_default()
}
}));
}
"tool_result" => {
let tool_use_id = block
.get("tool_use_id")
.and_then(|i| i.as_str())
.unwrap_or("");
let content_val = block.get("content");
let content_str = match content_val {
Some(Value::String(s)) => s.clone(),
Some(v) => serde_json::to_string(v).unwrap_or_default(),
None => String::new(),
};
result.push(json!({
"role": "tool",
"tool_call_id": tool_use_id,
"content": content_str
}));
}
"thinking" => {
if let Some(thinking) = block.get("thinking").and_then(|t| t.as_str()) {
if !thinking.is_empty() {
reasoning_parts.push(thinking.to_string());
}
}
}
_ => {}
}
}
if !content_parts.is_empty() || !tool_calls.is_empty() {
let mut msg = json!({"role": role});
if content_parts.is_empty() {
msg["content"] = Value::Null;
} else if content_parts.len() == 1 {
if content_parts[0].get("cache_control").is_some() {
msg["content"] = json!(content_parts);
} else if let Some(text) = content_parts[0].get("text") {
msg["content"] = text.clone();
} else {
msg["content"] = json!(content_parts);
}
} else {
msg["content"] = json!(content_parts);
}
if !tool_calls.is_empty() {
msg["tool_calls"] = json!(tool_calls);
}
if preserve_reasoning_content && role == "assistant" && !tool_calls.is_empty() {
let reasoning_content = if reasoning_parts.is_empty() {
"tool call".to_string()
} else {
reasoning_parts.join("\n")
};
msg["reasoning_content"] = json!(reasoning_content);
}
result.push(msg);
}
return Ok(result);
}
result.push(json!({"role": role, "content": content}));
Ok(result)
}
pub(crate) fn clean_schema(mut schema: Value) -> Value {
if let Some(obj) = schema.as_object_mut() {
if obj.get("format").and_then(|v| v.as_str()) == Some("uri") {
obj.remove("format");
}
if let Some(properties) = obj.get_mut("properties").and_then(|v| v.as_object_mut()) {
for (_, value) in properties.iter_mut() {
*value = clean_schema(value.clone());
}
}
if let Some(items) = obj.get_mut("items") {
*items = clean_schema(items.clone());
}
}
schema
}
pub fn openai_to_anthropic(body: Value) -> Result<Value, ProxyError> {
let choices = body
.get("choices")
.and_then(|c| c.as_array())
.ok_or_else(|| ProxyError::TransformError("No choices in response".to_string()))?;
let choice = choices
.first()
.ok_or_else(|| ProxyError::TransformError("Empty choices array".to_string()))?;
let message = choice
.get("message")
.ok_or_else(|| ProxyError::TransformError("No message in choice".to_string()))?;
let mut content = Vec::new();
let mut has_tool_use = false;
if let Some(reasoning_content) = message.get("reasoning_content").and_then(|r| r.as_str()) {
if !reasoning_content.is_empty() {
content.push(json!({"type": "thinking", "thinking": reasoning_content}));
}
}
if let Some(msg_content) = message.get("content") {
if let Some(text) = msg_content.as_str() {
if !text.is_empty() {
content.push(json!({"type": "text", "text": text}));
}
} else if let Some(parts) = msg_content.as_array() {
for part in parts {
let part_type = part.get("type").and_then(|t| t.as_str()).unwrap_or("");
match part_type {
"text" | "output_text" => {
if let Some(text) = part.get("text").and_then(|t| t.as_str()) {
if !text.is_empty() {
content.push(json!({"type": "text", "text": text}));
}
}
}
"refusal" => {
if let Some(refusal) = part.get("refusal").and_then(|r| r.as_str()) {
if !refusal.is_empty() {
content.push(json!({"type": "text", "text": refusal}));
}
}
}
_ => {}
}
}
}
}
if let Some(refusal) = message.get("refusal").and_then(|r| r.as_str()) {
if !refusal.is_empty() {
content.push(json!({"type": "text", "text": refusal}));
}
}
if let Some(tool_calls) = message.get("tool_calls").and_then(|t| t.as_array()) {
if !tool_calls.is_empty() {
has_tool_use = true;
}
for tc in tool_calls {
let id = tc.get("id").and_then(|i| i.as_str()).unwrap_or("");
let empty_obj = json!({});
let func = tc.get("function").unwrap_or(&empty_obj);
let name = func.get("name").and_then(|n| n.as_str()).unwrap_or("");
let args_str = func
.get("arguments")
.and_then(|a| a.as_str())
.unwrap_or("{}");
let input: Value = serde_json::from_str(args_str).unwrap_or(json!({}));
content.push(json!({
"type": "tool_use",
"id": id,
"name": name,
"input": input
}));
}
}
if !has_tool_use {
if let Some(function_call) = message.get("function_call") {
let id = function_call
.get("id")
.and_then(|i| i.as_str())
.unwrap_or("");
let name = function_call
.get("name")
.and_then(|n| n.as_str())
.unwrap_or("");
let has_arguments = function_call.get("arguments").is_some();
let input = match function_call.get("arguments") {
Some(Value::String(s)) => serde_json::from_str(s).unwrap_or(json!({})),
Some(v @ Value::Object(_)) | Some(v @ Value::Array(_)) => v.clone(),
_ => json!({}),
};
if !name.is_empty() || has_arguments {
content.push(json!({
"type": "tool_use",
"id": id,
"name": name,
"input": input
}));
has_tool_use = true;
}
}
}
let stop_reason = choice
.get("finish_reason")
.and_then(|r| r.as_str())
.map(|r| match r {
"stop" => "end_turn",
"length" => "max_tokens",
"tool_calls" | "function_call" => "tool_use",
"content_filter" => "end_turn",
other => {
log::warn!(
"[Claude/OpenAI] Unknown finish_reason in non-streaming response: {other}"
);
"end_turn"
}
})
.or(if has_tool_use { Some("tool_use") } else { None });
let usage = body.get("usage").cloned().unwrap_or(json!({}));
let input_tokens = usage
.get("prompt_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(0) as u32;
let output_tokens = usage
.get("completion_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(0) as u32;
let mut usage_json = json!({
"input_tokens": input_tokens,
"output_tokens": output_tokens
});
if let Some(cached) = usage
.pointer("/prompt_tokens_details/cached_tokens")
.and_then(|v| v.as_u64())
{
usage_json["cache_read_input_tokens"] = json!(cached);
}
if let Some(v) = usage.get("cache_read_input_tokens") {
usage_json["cache_read_input_tokens"] = v.clone();
}
if let Some(v) = usage.get("cache_creation_input_tokens") {
usage_json["cache_creation_input_tokens"] = v.clone();
}
Ok(json!({
"id": body.get("id").and_then(|i| i.as_str()).unwrap_or(""),
"type": "message",
"role": "assistant",
"content": content,
"model": body.get("model").and_then(|m| m.as_str()).unwrap_or(""),
"stop_reason": stop_reason,
"stop_sequence": null,
"usage": usage_json
}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn anthropic_to_openai_removes_billing_header_from_system_string() {
let input = json!({
"model": "gpt-5",
"system": "x-anthropic-billing-header: cc_version=2.1.120.cf9; cc_entrypoint=cli; cch=543cf;\nYou are helpful.",
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_openai(input, None).unwrap();
assert_eq!(result["messages"][0]["content"], "You are helpful.");
}
#[test]
fn anthropic_to_openai_removes_billing_header_from_system_array() {
let input = json!({
"model": "gpt-5",
"system": [{
"type": "text",
"text": "x-anthropic-billing-header: cc_version=2.1.120.cf9; cc_entrypoint=cli; cch=543cf;\nProject instructions",
"cache_control": {"type": "ephemeral"}
}],
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_openai(input, None).unwrap();
assert_eq!(result["messages"][0]["content"], "Project instructions");
assert_eq!(result["messages"][0]["cache_control"]["type"], "ephemeral");
}
#[test]
fn anthropic_to_openai_omits_empty_billing_header_system_block() {
let input = json!({
"model": "gpt-5",
"system": [{
"type": "text",
"text": "x-anthropic-billing-header: cc_version=2.1.120.cf9; cc_entrypoint=cli; cch=543cf;"
}],
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_openai(input, None).unwrap();
assert_eq!(result["messages"][0]["role"], "user");
}
#[test]
fn sanitize_system_text_preserves_remaining_content() {
let text = "First line\n x-anthropic-billing-header: cc_version=2.1.120.cf9; cc_entrypoint=cli; cch=543cf;\n\nLast line\n";
let result = sanitize_system_text(text).unwrap();
assert_eq!(result, "First line\n\nLast line\n");
}
#[test]
fn anthropic_to_openai_injects_prompt_cache_key() {
let input = json!({
"model": "claude-3-opus",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_openai(input, Some("provider-123")).unwrap();
assert_eq!(result["prompt_cache_key"], "provider-123");
}
#[test]
fn anthropic_to_openai_preserves_system_cache_control() {
let input = json!({
"model": "claude-3-sonnet",
"max_tokens": 1024,
"system": [{
"type": "text",
"text": "System prompt",
"cache_control": {"type": "ephemeral"}
}],
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_openai(input, None).unwrap();
assert_eq!(result["messages"][0]["role"], "system");
assert_eq!(result["messages"][0]["cache_control"]["type"], "ephemeral");
}
#[test]
fn anthropic_to_openai_preserves_text_block_cache_control_and_array_shape() {
let input = json!({
"model": "claude-3-opus",
"max_tokens": 1024,
"messages": [{
"role": "user",
"content": [{
"type": "text",
"text": "Hello",
"cache_control": {"type": "ephemeral", "ttl": "5m"}
}]
}]
});
let result = anthropic_to_openai(input, None).unwrap();
assert!(result["messages"][0]["content"].is_array());
assert_eq!(
result["messages"][0]["content"][0]["cache_control"]["type"],
"ephemeral"
);
assert_eq!(
result["messages"][0]["content"][0]["cache_control"]["ttl"],
"5m"
);
}
#[test]
fn anthropic_to_openai_preserves_tool_cache_control() {
let input = json!({
"model": "claude-3-opus",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "Hello"}],
"tools": [{
"name": "get_weather",
"description": "Get weather",
"input_schema": {"type": "object"},
"cache_control": {"type": "ephemeral"}
}]
});
let result = anthropic_to_openai(input, None).unwrap();
assert_eq!(result["tools"][0]["cache_control"]["type"], "ephemeral");
}
#[test]
fn anthropic_to_openai_maps_reasoning_effort_for_gpt5() {
let input = json!({
"model": "gpt-5.4",
"max_tokens": 1024,
"thinking": {"type": "adaptive"},
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_openai(input, None).unwrap();
assert_eq!(result["reasoning_effort"], "xhigh");
assert_eq!(result["max_tokens"], 1024);
}
#[test]
fn anthropic_to_openai_uses_max_completion_tokens_for_o_series() {
let input = json!({
"model": "o3-mini",
"max_tokens": 2048,
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_openai(input, None).unwrap();
assert_eq!(result["max_completion_tokens"], 2048);
assert!(result.get("max_tokens").is_none());
}
#[test]
fn anthropic_to_openai_does_not_emit_reasoning_content_by_default() {
let input = json!({
"model": "gpt-4o",
"messages": [{
"role": "assistant",
"content": [
{"type": "thinking", "thinking": "I should call the tool."},
{"type": "tool_use", "id": "call_1", "name": "get_weather", "input": {"city": "Tokyo"}}
]
}]
});
let result = anthropic_to_openai(input, None).unwrap();
assert!(result["messages"][0].get("reasoning_content").is_none());
}
#[test]
fn anthropic_to_openai_tool_use_preserves_reasoning_content_when_enabled() {
let input = json!({
"model": "deepseek-v4-pro",
"messages": [{
"role": "assistant",
"content": [
{"type": "thinking", "thinking": "I should call the tool."},
{"type": "tool_use", "id": "call_1", "name": "get_weather", "input": {"city": "Tokyo"}}
]
}]
});
let result = anthropic_to_openai_with_reasoning_content(input, None, true).unwrap();
assert_eq!(
result["messages"][0]["reasoning_content"],
"I should call the tool."
);
assert_eq!(result["messages"][0]["tool_calls"][0]["id"], "call_1");
}
#[test]
fn anthropic_to_openai_tool_use_injects_placeholder_reasoning_content_when_missing() {
let input = json!({
"model": "deepseek-v4-pro",
"messages": [{
"role": "assistant",
"content": [
{"type": "tool_use", "id": "call_1", "name": "get_weather", "input": {"city": "Tokyo"}}
]
}]
});
let result = anthropic_to_openai_with_reasoning_content(input, None, true).unwrap();
assert_eq!(result["messages"][0]["reasoning_content"], "tool call");
}
#[test]
fn openai_to_anthropic_maps_reasoning_content_to_thinking_block() {
let input = json!({
"id": "chatcmpl-deepseek",
"model": "deepseek-v4-flash",
"choices": [{
"message": {
"role": "assistant",
"reasoning_content": "Need the current date before calling weather.",
"content": "Let me check.",
"tool_calls": [{
"id": "call_date",
"type": "function",
"function": {"name": "get_date", "arguments": "{}"}
}]
},
"finish_reason": "tool_calls"
}],
"usage": {"prompt_tokens": 10, "completion_tokens": 5}
});
let result = openai_to_anthropic(input).unwrap();
assert_eq!(result["content"][0]["type"], "thinking");
assert_eq!(
result["content"][0]["thinking"],
"Need the current date before calling weather."
);
assert_eq!(result["content"][1]["type"], "text");
assert_eq!(result["content"][2]["type"], "tool_use");
}
#[test]
fn deepseek_reasoning_content_round_trips_for_tool_calls() {
let upstream_response = json!({
"id": "chatcmpl-deepseek",
"model": "deepseek-v4-flash",
"choices": [{
"message": {
"role": "assistant",
"reasoning_content": "Need the current date before calling weather.",
"content": "Let me check.",
"tool_calls": [{
"id": "call_date",
"type": "function",
"function": {"name": "get_date", "arguments": "{}"}
}]
},
"finish_reason": "tool_calls"
}],
"usage": {"prompt_tokens": 10, "completion_tokens": 5}
});
let anthropic_response = openai_to_anthropic(upstream_response).unwrap();
let follow_up_request = json!({
"model": "deepseek-v4-flash",
"messages": [{
"role": "assistant",
"content": anthropic_response["content"].clone()
}]
});
let replayed =
anthropic_to_openai_with_reasoning_content(follow_up_request, None, true).unwrap();
let msg = &replayed["messages"][0];
assert_eq!(
msg["reasoning_content"],
"Need the current date before calling weather."
);
assert_eq!(msg["tool_calls"][0]["id"], "call_date");
assert_eq!(msg["tool_calls"][0]["function"]["name"], "get_date");
}
}