, next: Next) ->
response
}
}
+// Anthropic content-block compatibility middleware
+// ---------------------------------------------------------------------------
+//
+// When a gateway translates Anthropic `/v1/messages` requests to OpenAI
+// `/v1/chat/completions` format it may leave Anthropic-specific content-block
+// types inside assistant and user `content` arrays. Those blocks are not valid
+// OpenAI message parts, so the request fails deserialization before reaching
+// the model.
+//
+// This middleware inspects the raw JSON body *before* axum's `Json` extractor
+// deserializes it. For assistant messages it converts:
+//
+// {type:"thinking", thinking:"…"} → reasoning_content (segments form)
+// {type:"redacted_thinking", …} → dropped (lossy: encrypted reasoning
+// has no OpenAI representation)
+// {type:"tool_use", id,name,input} → tool_calls[] entry
+// {type:"text", text:"…"} → content string
+// {type:"refusal", refusal:"…"} → refusal field
+//
+// For user messages, it converts each `{type:"tool_result", tool_use_id, …}`
+// block into a separate `role:"tool"` message and preserves intervening user
+// content in order.
+//
+// Thinking/tool interleaving is preserved: reasoning segments are emitted one
+// per tool-call position (segments.len() == tool_calls.len() + 1), matching
+// `convert_assistant_blocks` in the Anthropic protocol module. Text after a
+// tool call, or thinking after text before the first call, cannot be placed
+// faithfully in OpenAI's separate content/reasoning/tool_calls fields and is
+// rejected rather than reordered.
+//
+// Other unsupported or ambiguous input is rejected with 400: malformed
+// blocks (missing id/name/input, non-string payloads), unknown block types,
+// and partially-translated messages whose existing OpenAI fields
+// (tool_calls / reasoning_content) cannot be ordered against the residual
+// blocks (see check_mixed_format_conflicts). Redacted thinking remains an
+// explicit lossy exception because its encrypted payload has no OpenAI form.
+
+/// Anthropic content-block types that trigger conversion.
+const ANTHROPIC_BLOCK_TYPES: &[&str] = &["thinking", "tool_use", "redacted_thinking"];
+
+/// Returns `true` if the JSON value is a content-block array that contains
+/// at least one Anthropic-specific part type requiring conversion.
+fn has_anthropic_content_blocks(content: &serde_json::Value) -> bool {
+ let Some(arr) = content.as_array() else {
+ return false;
+ };
+ arr.iter().any(|part| {
+ part.get("type")
+ .and_then(|t| t.as_str())
+ .map(|t| ANTHROPIC_BLOCK_TYPES.contains(&t))
+ .unwrap_or(false)
+ })
+}
+
+/// Convert an Anthropic content-block array into OpenAI fields.
+///
+/// Returns `(content, reasoning_segments, tool_calls, refusal)` or an error
+/// message when the input cannot be converted without reordering or silently
+/// dropping data. `redacted_thinking` is the explicit lossy exception.
+///
+/// `reasoning_segments` has one entry per tool-call position:
+/// `segments[i]` is the thinking that preceded `tool_calls[i]`,
+/// `segments[tool_calls.len()]` is trailing reasoning. This preserves
+/// the interleaved order matching `convert_assistant_blocks` in
+/// `protocols/anthropic/types.rs`.
+///
+/// Strict validation (rejects instead of guessing):
+/// - tool_use blocks missing `id` or `name` are rejected;
+/// - tool_use blocks missing `input`, or with a non-object `input`, are
+/// rejected (the OpenAI `arguments` field must serialize an object);
+/// - text/thinking/refusal blocks with a missing or non-string payload are
+/// rejected (silently skipping them would convert malformed input into a
+/// valid-looking shape);
+/// - duplicate tool_use ids within the block list are rejected;
+/// - unknown or unsupported block types (e.g. `server_tool_use`) are
+/// rejected rather than silently dropped.
+/// - text after a tool_use, or thinking after text before the first tool_use,
+/// is rejected because separate OpenAI fields cannot preserve that order.
+fn convert_anthropic_blocks(
+ blocks: &[serde_json::Value],
+) -> Result<
+ (
+ Option,
+ Option<(bool, Vec)>,
+ Option>,
+ Option,
+ ),
+ String,
+> {
+ let mut text_content = String::new();
+ let mut refusal_content: Option = None;
+ let mut tool_calls: Vec = Vec::new();
+ let mut segments: Vec = Vec::new();
+ let mut pending_reasoning = String::new();
+ let mut seen_ids: std::collections::HashSet = std::collections::HashSet::new();
+ let mut seen_text = false;
+ let mut seen_tool_use = false;
+
+ for (block_idx, block) in blocks.iter().enumerate() {
+ let part_type = block.get("type").and_then(|t| t.as_str()).unwrap_or("");
+ match part_type {
+ "text" => match block.get("text").and_then(|t| t.as_str()) {
+ Some(t) => {
+ if seen_tool_use && !t.is_empty() {
+ return Err(format!(
+ "text after tool_use cannot be represented without changing block order (block index {block_idx})"
+ ));
+ }
+ text_content.push_str(t);
+ seen_text |= !t.is_empty();
+ }
+ None => {
+ return Err(format!(
+ "text block is missing a string 'text' field (block index {block_idx})"
+ ));
+ }
+ },
+ "thinking" => match block.get("thinking").and_then(|t| t.as_str()) {
+ Some(t) => {
+ if seen_text && !seen_tool_use && !t.is_empty() {
+ return Err(format!(
+ "thinking after text before the first tool_use cannot be represented without changing block order (block index {block_idx})"
+ ));
+ }
+ if !pending_reasoning.is_empty() {
+ pending_reasoning.push('\n');
+ }
+ pending_reasoning.push_str(t);
+ }
+ None => {
+ return Err(format!(
+ "thinking block is missing a string 'thinking' field (block index {block_idx})"
+ ));
+ }
+ },
+ "redacted_thinking" => {
+ // Redacted thinking is encrypted model reasoning that cannot
+ // be read or represented in OpenAI format. Its data is
+ // dropped (a lossy but explicit degradation); the block
+ // itself does not add reasoning text.
+ }
+ "tool_use" => {
+ seen_tool_use = true;
+ // Snapshot the reasoning that preceded this tool call.
+ segments.push(std::mem::take(&mut pending_reasoning));
+
+ // Reject missing required fields rather than fabricating
+ // empty ones (R3): a call without id or name is a malformed
+ // block, not a convertible one.
+ let id = block
+ .get("id")
+ .and_then(|v| v.as_str())
+ .filter(|s| !s.is_empty())
+ .ok_or_else(|| "tool_use block is missing a non-empty 'id' field".to_string())?
+ .to_string();
+ let name = block
+ .get("name")
+ .and_then(|v| v.as_str())
+ .filter(|s| !s.is_empty())
+ .ok_or_else(|| {
+ "tool_use block is missing a non-empty 'name' field".to_string()
+ })?
+ .to_string();
+
+ // Reject duplicate ids among the new blocks themselves (R3).
+ if !seen_ids.insert(id.clone()) {
+ return Err(format!(
+ "duplicate tool_use id within assistant content blocks (block index {block_idx})"
+ ));
+ }
+
+ // Require `input` to be present and an object: the OpenAI
+ // `arguments` field is a serialized JSON object, and
+ // fabricating `"null"` for a missing input would silently
+ // convert a malformed block into a valid-looking call.
+ let input = block
+ .get("input")
+ .ok_or_else(|| {
+ format!(
+ "tool_use block is missing an 'input' field (block index {block_idx})"
+ )
+ })?
+ .clone();
+ if !input.is_object() {
+ return Err(format!(
+ "tool_use 'input' must be an object (block index {block_idx})"
+ ));
+ }
+ let arguments = serde_json::to_string(&input).map_err(|e| {
+ format!("failed to serialize tool_use input (block index {block_idx}): {e}")
+ })?;
+ tool_calls.push(serde_json::json!({
+ "id": id,
+ "type": "function",
+ "function": {
+ "name": name,
+ "arguments": arguments,
+ }
+ }));
+ }
+ "refusal" => {
+ match block.get("refusal").and_then(|r| r.as_str()) {
+ Some(r) => {
+ // Multiple refusal blocks are merged with a newline
+ // separator so no earlier block is silently dropped.
+ match &mut refusal_content {
+ Some(existing) => {
+ existing.push('\n');
+ existing.push_str(r);
+ }
+ None => refusal_content = Some(r.to_string()),
+ }
+ }
+ None => {
+ return Err(format!(
+ "refusal block is missing a string 'refusal' field (block index {block_idx})"
+ ));
+ }
+ }
+ }
+ // Unknown/unsupported block types (e.g. `server_tool_use`, future
+ // Anthropic types) are rejected explicitly. Silently ignoring
+ // them here would delete data while reporting success, because
+ // the whole content array is replaced below.
+ _ => {
+ return Err(format!(
+ "unsupported content block type in Anthropic block array (block index {block_idx}); \
+ only text/thinking/redacted_thinking/tool_use/refusal can be converted"
+ ));
+ }
+ }
+ }
+
+ // Append trailing reasoning (after the last tool call) as the final segment.
+ segments.push(std::mem::take(&mut pending_reasoning));
+
+ let content = if text_content.is_empty() {
+ None
+ } else {
+ Some(text_content)
+ };
+
+ // Determine reasoning_content representation:
+ // - Segments (array) when there are tool calls and at least one non-empty segment.
+ // - Text (string) when there's reasoning but no tool calls.
+ // - None when there's no reasoning at all.
+ //
+ // `ReasoningContent` is `#[serde(untagged)]`:
+ // Text(String) deserializes from a JSON string
+ // Segments(Vec) deserializes from a JSON array of strings
+ let reasoning_segments: Option<(bool, Vec)> =
+ if !tool_calls.is_empty() && segments.iter().any(|s| !s.is_empty()) {
+ Some((true, segments))
+ } else {
+ let flat: String = segments
+ .iter()
+ .filter(|s| !s.is_empty())
+ .cloned()
+ .collect::>()
+ .join("\n");
+ if flat.is_empty() {
+ None
+ } else {
+ Some((false, vec![flat]))
+ }
+ };
+
+ let tc = if tool_calls.is_empty() {
+ None
+ } else {
+ Some(tool_calls)
+ };
+
+ Ok((content, reasoning_segments, tc, refusal_content))
+}
+
+/// Mixed-format conflict policy for one assistant message (review F1).
+///
+/// When a gateway has only *partially* translated an Anthropic history
+/// message, the message can carry OpenAI fields (`tool_calls` /
+/// `reasoning_content`) while its content array still holds residual
+/// Anthropic blocks. Some of those combinations cannot be merged without
+/// inventing position information, so all of them are rejected:
+///
+/// - existing `tool_calls` + residual `tool_use` blocks: the relative order
+/// of old and new calls is unknown;
+/// - existing `tool_calls` + residual `thinking` blocks: the position of
+/// the new reasoning relative to the existing calls is unknown (e.g.
+/// history `thinking A → t1 → thinking B → t2` with the tool blocks
+/// stripped by the gateway collapses to `A\nB`, losing that B followed
+/// t1);
+/// - existing `reasoning_content` + residual `thinking` blocks: merging
+/// would lose ordering or overwrite existing reasoning;
+/// - existing `reasoning_content` + residual `tool_use` blocks: the
+/// segment/call length correspondence cannot be validated, and matching
+/// counts alone do not prove matching positions.
+///
+/// Residual `text` blocks (and `refusal`, handled separately below) merge
+/// cleanly and never trigger this policy.
+fn check_mixed_format_conflicts(
+ has_existing_calls: bool,
+ has_existing_reasoning: bool,
+ produced_calls: bool,
+ produced_reasoning: bool,
+) -> Result<(), String> {
+ if has_existing_calls && produced_calls {
+ return Err("assistant message mixes an existing tool_calls array with \
+ residual Anthropic tool_use blocks; the original call order \
+ cannot be determined, ask the gateway to fully translate \
+ tool calls before forwarding"
+ .to_string());
+ }
+ if has_existing_calls && produced_reasoning {
+ return Err(
+ "assistant message has an existing tool_calls array while its \
+ content array still contains thinking blocks; the position of \
+ the new reasoning relative to those calls cannot be determined, \
+ ask the gateway to fully translate the message before forwarding"
+ .to_string(),
+ );
+ }
+ if has_existing_reasoning && produced_reasoning {
+ return Err("assistant message already has reasoning_content while its \
+ content array still contains thinking blocks; the two cannot \
+ be combined without losing ordering, ask the gateway to avoid \
+ translating reasoning twice"
+ .to_string());
+ }
+ if has_existing_reasoning && produced_calls {
+ return Err("assistant message already has reasoning_content while its \
+ content array still contains tool_use blocks; the new calls \
+ cannot be positioned within the existing reasoning, ask the \
+ gateway to fully translate the message before forwarding"
+ .to_string());
+ }
+ Ok(())
+}
+
+/// Build a 400 rejection response from the anthropic compat middleware.
+///
+/// The work-in-progress modified JSON is discarded; only the byte length of
+/// the original request is logged, never the content itself, so no user
+/// data leaks into error responses or logs.
+#[must_use]
+fn anthropic_compat_reject(byte_len: usize, message: String) -> Response {
+ tracing::warn!(
+ byte_len,
+ "anthropic_content_compat: rejecting request: {message}"
+ );
+ let code = StatusCode::BAD_REQUEST;
+ (
+ code,
+ Json(ErrorMessage {
+ message,
+ error_type: map_error_code_to_error_type(code),
+ code: code.as_u16(),
+ details: None,
+ }),
+ )
+ .into_response()
+}
+
+/// Convert one text-only Anthropic tool_result block to an OpenAI tool message.
+/// Image results are rejected because the tool-message media path is not
+/// preprocessed by Dynamo.
+fn convert_anthropic_tool_result(block: &serde_json::Value) -> Result {
+ let tool_call_id = block
+ .get("tool_use_id")
+ .and_then(serde_json::Value::as_str)
+ .filter(|id| !id.is_empty())
+ .ok_or_else(|| {
+ "tool_result block is missing a non-empty 'tool_use_id' field".to_string()
+ })?;
+
+ let is_error = match block.get("is_error") {
+ None | Some(serde_json::Value::Null) => false,
+ Some(serde_json::Value::Bool(value)) => *value,
+ Some(_) => return Err("tool_result 'is_error' must be a boolean".to_string()),
+ };
+
+ let content = match block.get("content") {
+ None | Some(serde_json::Value::Null) => serde_json::Value::String(String::new()),
+ Some(serde_json::Value::String(text)) => serde_json::Value::String(if is_error {
+ format!("[tool error] {text}")
+ } else {
+ text.clone()
+ }),
+ Some(serde_json::Value::Array(parts)) => {
+ let mut converted = Vec::with_capacity(parts.len() + if is_error { 1 } else { 0 });
+ if is_error {
+ converted.push(serde_json::json!({"type": "text", "text": "[tool error]"}));
+ }
+ for (part_idx, part) in parts.iter().enumerate() {
+ match part.get("type").and_then(serde_json::Value::as_str) {
+ Some("text") => {
+ let text = part
+ .get("text")
+ .and_then(serde_json::Value::as_str)
+ .ok_or_else(|| {
+ format!("tool_result content block {part_idx} is missing a string 'text' field")
+ })?;
+ converted.push(serde_json::json!({"type": "text", "text": text}));
+ }
+ Some("image") => {
+ return Err(format!(
+ "image blocks in tool_result content are not supported (block index {part_idx})"
+ ));
+ }
+ _ => {
+ return Err(format!(
+ "unsupported tool_result content block type at index {part_idx}"
+ ));
+ }
+ }
+ }
+ serde_json::Value::Array(converted)
+ }
+ Some(_) => {
+ return Err("tool_result 'content' must be a string or an array".to_string());
+ }
+ };
+
+ Ok(serde_json::json!({
+ "role": "tool",
+ "tool_call_id": tool_call_id,
+ "content": content,
+ }))
+}
+
+/// Split Anthropic tool_result blocks out of a user message, retaining
+/// ordinary user content in place between the generated tool messages.
+fn convert_anthropic_user_tool_results(
+ message: &serde_json::Value,
+) -> Result