use super::{ProviderRequest, anthropic::anthropic_messages_body_with_cache_ttl};
use crate::thinking::ThinkingLevel;
use anyhow::{Context, Result, bail};
use serde_json::{Value, json};
use std::collections::HashSet;
const TOOL_PREFIX: &str = "mcp__magi__";
pub(super) struct ClaudeProjection {
pub(super) system: String,
pub(super) frames: Vec<Value>,
pub(super) manifest: Vec<Value>,
pub(super) extra_body: Value,
}
impl ClaudeProjection {
pub(super) fn from_request(request: &ProviderRequest) -> Result<Self> {
let mut body = anthropic_messages_body_with_cache_ttl(
&request.model,
request,
None,
None,
ThinkingLevel::Default,
);
let system = body
.get("system")
.and_then(Value::as_str)
.unwrap_or("")
.to_owned();
let messages = body
.get("messages")
.and_then(Value::as_array)
.context("Claude conversation missing messages")?;
let mut frames: Vec<Value> = Vec::new();
for message in messages {
let role = message
.get("role")
.and_then(Value::as_str)
.context("Claude conversation message missing role")?;
if !matches!(role, "user" | "assistant") {
bail!("Claude conversation has unsupported role");
}
let mut content = message
.get("content")
.and_then(Value::as_array)
.context("Claude conversation message missing content")?
.clone();
content.retain(|block| {
!matches!(
block.get("type").and_then(Value::as_str),
Some("thinking" | "redacted_thinking")
)
});
for block in &mut content {
if block.get("type").and_then(Value::as_str) == Some("tool_use") {
let name = block
.get("name")
.and_then(Value::as_str)
.context("Claude replayed tool call missing name")?;
if name.is_empty()
|| name.len() > 50
|| !name.bytes().all(|byte| {
byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-'
})
{
bail!("Claude replayed tool has invalid name");
}
block["name"] = json!(format!("{TOOL_PREFIX}{name}"));
}
}
if content.is_empty() {
continue;
}
if frames
.last()
.and_then(|frame| frame.get("type"))
.and_then(Value::as_str)
== Some(role)
{
if let Some(parts) = frames
.last_mut()
.and_then(|frame| frame.pointer_mut("/message/content"))
.and_then(Value::as_array_mut)
{
parts.extend(content);
}
} else {
frames.push(json!({"type":role,"message":{"role":role,"content":content}}));
}
}
if frames
.last()
.and_then(|frame| frame.get("type"))
.and_then(Value::as_str)
!= Some("user")
|| frames
.last()
.and_then(|frame| frame.pointer("/message/content"))
.and_then(Value::as_array)
.is_none_or(Vec::is_empty)
{
bail!("Claude conversation must end with a nonempty user or tool result message");
}
let replay_count = frames.len().saturating_sub(1);
for frame in frames.iter_mut().take(replay_count) {
if frame["type"] == "user" {
frame["shouldQuery"] = json!(false);
}
}
let mut names = HashSet::new();
let mut manifest = Vec::new();
let mut tools = Vec::new();
for tool in body
.get("tools")
.and_then(Value::as_array)
.into_iter()
.flatten()
{
let name = tool
.get("name")
.and_then(Value::as_str)
.context("Claude tool missing name")?;
if name.is_empty()
|| name.len() > 50
|| !name
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-')
|| !names.insert(name)
{
bail!("Claude tool names must be distinct ASCII identifiers (maximum 50 bytes)");
}
let schema = tool
.get("input_schema")
.filter(|schema| schema.is_object())
.context("Claude tool requires an object schema")?;
if ["oneOf", "allOf", "anyOf"]
.iter()
.any(|key| schema.get(*key).is_some())
{
bail!("Claude tool schema contains unsupported top-level alternatives");
}
let description = tool
.get("description")
.and_then(Value::as_str)
.unwrap_or("");
manifest.push(json!({"name":name,"description":description,"inputSchema":schema}));
tools.push(json!({"name":format!("{TOOL_PREFIX}{name}"),"description":description,"input_schema":schema}));
}
body["tools"] = json!(tools);
let extra_body = json!({"tools":body["tools"]});
Ok(Self {
system,
frames,
manifest,
extra_body,
})
}
}