diff --git a/lib/llm/src/http/service/openai.rs b/lib/llm/src/http/service/openai.rs index d176c4442ed6..34aa1a89c17e 100644 --- a/lib/llm/src/http/service/openai.rs +++ b/lib/llm/src/http/service/openai.rs @@ -487,6 +487,703 @@ pub async fn smart_json_error_middleware(request: Request, 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>, String> { + let Some(blocks) = message.get("content").and_then(serde_json::Value::as_array) else { + return Ok(None); + }; + if !blocks + .iter() + .any(|block| block.get("type").and_then(serde_json::Value::as_str) == Some("tool_result")) + { + return Ok(None); + } + + let mut converted = Vec::new(); + let mut user_parts = Vec::new(); + for (block_idx, block) in blocks.iter().enumerate() { + if block.get("type").and_then(serde_json::Value::as_str) == Some("tool_result") { + if !user_parts.is_empty() { + let mut user_message = message.clone(); + user_message["content"] = serde_json::Value::Array(std::mem::take(&mut user_parts)); + converted.push(user_message); + } + converted.push( + convert_anthropic_tool_result(block) + .map_err(|error| format!("content block {block_idx}: {error}"))?, + ); + } else { + // Keep ordinary content in place, but remove Anthropic-only cache + // hints that have no OpenAI equivalent. + let mut part = block.clone(); + if let Some(object) = part.as_object_mut() { + object.remove("cache_control"); + } + user_parts.push(part); + } + } + if !user_parts.is_empty() { + let mut user_message = message.clone(); + user_message["content"] = serde_json::Value::Array(user_parts); + converted.push(user_message); + } + + Ok(Some(converted)) +} + +/// Middleware that normalizes Anthropic content blocks in OpenAI chat +/// completion requests before deserialization. +pub async fn anthropic_content_compat_middleware(request: Request, next: Next) -> Response { + let (mut parts, body) = request.into_parts(); + + let body_bytes = match axum::body::to_bytes(body, get_body_limit()).await { + Ok(bytes) => bytes, + Err(e) => { + // Preserve the original error semantics instead of masking + // them: a length-limit failure surfaces as 413 Payload Too + // Large, other read failures as 400 from this middleware. + let status = if e.to_string().contains("length limit") { + StatusCode::PAYLOAD_TOO_LARGE + } else { + StatusCode::BAD_REQUEST + }; + return ( + status, + Json(ErrorMessage { + message: format!("Failed to read request body: {e}"), + error_type: map_error_code_to_error_type(status), + code: status.as_u16(), + details: None, + }), + ) + .into_response(); + } + }; + + // Fast path: try to parse as JSON. If it fails, pass through untouched + // so the downstream Json extractor produces the standard parse error. + let mut json: serde_json::Value = match serde_json::from_slice(&body_bytes) { + Ok(v) => v, + Err(_) => { + return next + .run(Request::from_parts(parts, Body::from(body_bytes))) + .await; + } + }; + + // Walk messages, convert assistant blocks first, then split user tool + // results. This keeps assistant tool_calls ahead of the resulting tool + // messages in the request history. + let modified = if let Some(messages) = json.get_mut("messages").and_then(|m| m.as_array_mut()) { + let mut changed = false; + for (msg_idx, msg) in messages.iter_mut().enumerate() { + // Only process assistant messages. + let is_assistant = msg + .get("role") + .and_then(|r| r.as_str()) + .map(|r| r == "assistant") + .unwrap_or(false); + if !is_assistant { + continue; + } + + // Check for residual Anthropic blocks using an immutable borrow + // only; `msg` stays free for the next mutable borrow below. + let blocks: Vec = match msg.get("content") { + Some(c) if has_anthropic_content_blocks(c) => { + c.as_array().cloned().unwrap_or_default() + } + _ => continue, + }; + + // Convert the residual blocks; malformed input (missing ids, + // duplicate ids) is rejected with a clear 400 instead of + // silently fabricating or merging unprovable history (R3). + // Errors are prefixed with the message index so clients can + // locate the offending block as messages[i].content[j]. + let (text, reasoning_segments, tool_calls, refusal) = + match convert_anthropic_blocks(&blocks) { + Ok(v) => v, + Err(e) => { + return anthropic_compat_reject( + body_bytes.len(), + format!("messages[{msg_idx}]: {e}"), + ); + } + }; + + let obj = match msg.as_object_mut() { + Some(o) => o, + None => continue, + }; + + // Mixed-format conflict policy (R1/R2 + review F1): when the + // gateway has only partially translated this message, OpenAI + // fields coexist with residual Anthropic blocks. Every + // combination whose ordering cannot be proven is rejected + // instead of guessed — including the two cross combinations: + // existing tool_calls with residual thinking, and existing + // reasoning_content with residual tool_use. + // + // Structural validation comes first (review v4 R1): a + // non-null, non-array `tool_calls` is a gateway format error. + // Treating it as "no existing calls" would let the insert + // below silently overwrite (delete) the original field and + // normalize the malformed request into a valid-looking one. + let has_existing_calls = match obj.get("tool_calls") { + None | Some(serde_json::Value::Null) => false, + Some(serde_json::Value::Array(calls)) => !calls.is_empty(), + Some(_) => { + return anthropic_compat_reject( + body_bytes.len(), + format!("messages[{msg_idx}].tool_calls must be an array or null"), + ); + } + }; + let has_existing_reasoning = obj + .get("reasoning_content") + .map(|v| !v.is_null()) + .unwrap_or(false); + // `tool_calls` is None exactly when no calls were produced (see + // convert_anthropic_blocks), so is_some() means produced calls. + if let Err(e) = check_mixed_format_conflicts( + has_existing_calls, + has_existing_reasoning, + tool_calls.is_some(), + reasoning_segments.is_some(), + ) { + return anthropic_compat_reject( + body_bytes.len(), + format!("messages[{msg_idx}]: {e}"), + ); + } + + // R4: refusal — fill when missing or null, reject on conflict. + if let Some(r) = &refusal { + match obj.get("refusal") { + None | Some(serde_json::Value::Null) => { + obj.insert("refusal".into(), r.clone().into()); + } + Some(existing) if existing.as_str() == Some(r.as_str()) => { + // Same value already present: keep one copy. + } + Some(_) => { + return anthropic_compat_reject( + body_bytes.len(), + "assistant message has a conflicting refusal value \ + alongside residual Anthropic refusal blocks" + .to_string(), + ); + } + } + } + + obj.insert( + "content".into(), + match text { + Some(t) => serde_json::Value::String(t), + None => serde_json::Value::Null, + }, + ); + + // Set reasoning_content when missing or null (conflicts were + // rejected above). + if let Some((is_segments, segs)) = reasoning_segments { + let new_rc = if is_segments { + // Segments form: bare JSON array of strings — preserves + // the interleaved order (segments.len() == tool_calls.len() + 1). + serde_json::Value::Array( + segs.into_iter().map(serde_json::Value::String).collect(), + ) + } else { + // Text form: bare JSON string. + serde_json::Value::String(segs.into_iter().next().unwrap_or_default()) + }; + obj.insert("reasoning_content".into(), new_rc); + } + + // Set tool_calls only when there are no existing calls (mixing + // was rejected above). Same-id dedup is unnecessary here because + // duplicate ids within the blocks were rejected during + // conversion (R3). + if let Some(tc) = tool_calls { + obj.insert("tool_calls".into(), tc.into()); + } + + changed = true; + } + + let original_messages = std::mem::take(messages); + let mut normalized_messages = Vec::with_capacity(original_messages.len()); + for (msg_idx, msg) in original_messages.into_iter().enumerate() { + if msg.get("role").and_then(serde_json::Value::as_str) == Some("user") { + match convert_anthropic_user_tool_results(&msg) { + Ok(Some(converted)) => { + normalized_messages.extend(converted); + changed = true; + } + Ok(None) => normalized_messages.push(msg), + Err(error) => { + return anthropic_compat_reject( + body_bytes.len(), + format!("messages[{msg_idx}]: {error}"), + ); + } + } + } else { + normalized_messages.push(msg); + } + } + *messages = normalized_messages; + changed + } else { + false + }; + + let new_body = if modified { + // Conversion changes the byte length; drop the stale + // Content-Length so downstream layers do not read a truncated or + // over-long body. axum/http recompute it from the new Body. + parts.headers.remove(axum::http::header::CONTENT_LENGTH); + Body::from(serde_json::to_vec(&json).unwrap_or_else(|_| body_bytes.to_vec())) + } else { + Body::from(body_bytes) + }; + + next.run(Request::from_parts(parts, new_body)).await +} /// Return the request ID for the current request. /// @@ -2559,6 +3256,7 @@ pub fn chat_completions_router( let doc = RouteDoc::new(axum::http::Method::POST, &path); let router = Router::new() .route(&path, post(handler_chat_completions)) + .layer(middleware::from_fn(anthropic_content_compat_middleware)) .layer(middleware::from_fn(smart_json_error_middleware)) .layer(axum::extract::DefaultBodyLimit::max(get_body_limit())) .with_state((state, template)); @@ -5846,4 +6544,996 @@ mod tests { "error should mention the multiple-of-4 check, got: {err_msg}" ); } + + // ----------------------------------------------------------------------- + // Anthropic content-block compatibility middleware tests + // ----------------------------------------------------------------------- + + /// Helper: build a JSON content-block array from a vec of (type, json) pairs. + fn make_blocks(parts: Vec<(&str, serde_json::Value)>) -> Vec { + parts + .into_iter() + .map(|(t, v)| { + let mut obj = serde_json::Map::new(); + obj.insert("type".into(), serde_json::Value::String(t.to_string())); + match v { + serde_json::Value::Object(map) => obj.extend(map), + other => { + obj.insert(t.to_string(), other); + } + } + serde_json::Value::Object(obj) + }) + .collect() + } + + #[test] + fn test_has_anthropic_content_blocks_detects_thinking() { + let content = serde_json::json!([ + {"type": "thinking", "thinking": "hmm", "signature": "s"}, + {"type": "text", "text": "hello"} + ]); + assert!(has_anthropic_content_blocks(&content)); + } + + #[test] + fn test_has_anthropic_content_blocks_detects_tool_use() { + let content = serde_json::json!([ + {"type": "tool_use", "id": "t1", "name": "fn", "input": {}} + ]); + assert!(has_anthropic_content_blocks(&content)); + } + + #[test] + fn test_has_anthropic_content_blocks_detects_redacted_thinking() { + let content = serde_json::json!([ + {"type": "redacted_thinking", "data": "encrypted"} + ]); + assert!(has_anthropic_content_blocks(&content)); + } + + #[test] + fn test_has_anthropic_content_blocks_rejects_pure_openai() { + let content = serde_json::json!([ + {"type": "text", "text": "hello"} + ]); + assert!(!has_anthropic_content_blocks(&content)); + } + + #[test] + fn test_has_anthropic_content_blocks_rejects_string() { + let content = serde_json::json!("hello"); + assert!(!has_anthropic_content_blocks(&content)); + } + + #[test] + fn test_has_anthropic_content_blocks_rejects_null() { + let content = serde_json::Value::Null; + assert!(!has_anthropic_content_blocks(&content)); + } + + #[test] + fn test_convert_simple_text_only() { + let blocks = make_blocks(vec![("text", serde_json::json!("hello"))]); + let (content, reasoning, tool_calls, refusal) = convert_anthropic_blocks(&blocks).unwrap(); + assert_eq!(content.as_deref(), Some("hello")); + assert!(reasoning.is_none()); + assert!(tool_calls.is_none()); + assert!(refusal.is_none()); + } + + #[test] + fn test_convert_thinking_only_flat_text() { + let blocks = make_blocks(vec![ + ("thinking", serde_json::json!("let me think")), + ("text", serde_json::json!("answer")), + ]); + let (content, reasoning, tool_calls, refusal) = convert_anthropic_blocks(&blocks).unwrap(); + assert_eq!(content.as_deref(), Some("answer")); + let (is_segments, segs) = reasoning.expect("should have reasoning"); + assert!(!is_segments, "flat reasoning should be Text variant"); + assert_eq!(segs, vec!["let me think"]); + assert!(tool_calls.is_none()); + assert!(refusal.is_none()); + } + + #[test] + fn test_convert_tool_use_basic() { + let blocks = make_blocks(vec![ + ("text", serde_json::json!("It's sunny.")), + ( + "tool_use", + serde_json::json!({"id": "t1", "name": "get_weather", "input": {"city": "Beijing"}}), + ), + ]); + let (content, reasoning, tool_calls, refusal) = convert_anthropic_blocks(&blocks).unwrap(); + assert_eq!(content.as_deref(), Some("It's sunny.")); + assert!(reasoning.is_none(), "no thinking → no reasoning"); + let tc = tool_calls.expect("should have tool_calls"); + assert_eq!(tc.len(), 1); + assert_eq!(tc[0]["id"], "t1"); + assert_eq!(tc[0]["type"], "function"); + assert_eq!(tc[0]["function"]["name"], "get_weather"); + assert_eq!( + tc[0]["function"]["arguments"], + serde_json::json!({"city":"Beijing"}).to_string() + ); + assert!(refusal.is_none()); + } + + #[test] + fn test_convert_anthropic_tool_result_text_content() { + let text_result = serde_json::json!({ + "type": "tool_result", + "tool_use_id": "t1", + "content": "first result" + }); + assert_eq!( + convert_anthropic_tool_result(&text_result).unwrap(), + serde_json::json!({ + "role": "tool", + "tool_call_id": "t1", + "content": "first result" + }) + ); + + let block_result = serde_json::json!({ + "type": "tool_result", + "tool_use_id": "t2", + "is_error": true, + "content": [{ + "type": "text", + "text": "second result", + "cache_control": {"type": "ephemeral"} + }] + }); + assert_eq!( + convert_anthropic_tool_result(&block_result).unwrap(), + serde_json::json!({ + "role": "tool", + "tool_call_id": "t2", + "content": [ + {"type": "text", "text": "[tool error]"}, + {"type": "text", "text": "second result"} + ] + }) + ); + } + + #[test] + fn test_convert_anthropic_tool_result_rejects_image_content() { + let image_result = serde_json::json!({ + "type": "tool_result", + "tool_use_id": "t1", + "content": [{ + "type": "image", + "source": { + "type": "base64", + "media_type": "image/png", + "data": "aGVsbG8=" + } + }] + }); + + let error = convert_anthropic_tool_result(&image_result).unwrap_err(); + assert!(error.contains("image blocks in tool_result content are not supported")); + } + + #[test] + fn test_convert_anthropic_user_tool_results_splits_in_order_and_strips_cache_control() { + let message = serde_json::json!({ + "role": "user", + "content": [ + {"type": "text", "text": "before", "cache_control": {"type": "ephemeral"}}, + {"type": "tool_result", "tool_use_id": "t1", "content": "first result"}, + {"type": "tool_result", "tool_use_id": "t2", "content": [ + {"type": "text", "text": "second result"} + ]}, + {"type": "text", "text": "after", "cache_control": {"type": "ephemeral"}} + ] + }); + + let converted = convert_anthropic_user_tool_results(&message) + .unwrap() + .expect("tool_result blocks should trigger conversion"); + assert_eq!( + converted, + vec![ + serde_json::json!({ + "role": "user", + "content": [{"type": "text", "text": "before"}] + }), + serde_json::json!({ + "role": "tool", + "tool_call_id": "t1", + "content": "first result" + }), + serde_json::json!({ + "role": "tool", + "tool_call_id": "t2", + "content": [{"type": "text", "text": "second result"}] + }), + serde_json::json!({ + "role": "user", + "content": [{"type": "text", "text": "after"}] + }) + ] + ); + } + + #[test] + fn test_convert_interleaved_thinking_tool_use_preserves_order() { + // thinking A → text → tool_use 1 → thinking B → tool_use 2 + let blocks = make_blocks(vec![ + ("thinking", serde_json::json!("thinking A")), + ("text", serde_json::json!("done")), + ( + "tool_use", + serde_json::json!({"id": "t1", "name": "fn1", "input": {}}), + ), + ("thinking", serde_json::json!("thinking B")), + ( + "tool_use", + serde_json::json!({"id": "t2", "name": "fn2", "input": {}}), + ), + ]); + let (content, reasoning, tool_calls, refusal) = convert_anthropic_blocks(&blocks).unwrap(); + assert_eq!(content.as_deref(), Some("done")); + + let (is_segments, segs) = reasoning.expect("should have reasoning"); + assert!( + is_segments, + "interleaved reasoning should be Segments variant" + ); + // segments[0] precedes tool_calls[0], segments[1] precedes tool_calls[1], + // segments[2] is trailing. segments.len() == tool_calls.len() + 1 + assert_eq!( + segs.len(), + 3, + "segments should have tool_calls.len()+1 entries" + ); + assert_eq!(segs[0], "thinking A"); + assert_eq!(segs[1], "thinking B"); + assert_eq!(segs[2], "", "trailing segment should be empty"); + + let tc = tool_calls.expect("should have tool_calls"); + assert_eq!(tc.len(), 2); + assert_eq!(tc[0]["id"], "t1"); + assert_eq!(tc[1]["id"], "t2"); + assert!(refusal.is_none()); + } + + #[test] + fn test_convert_redacted_thinking_alone() { + let blocks = make_blocks(vec![ + ("redacted_thinking", serde_json::json!("encrypted")), + ("text", serde_json::json!("hello")), + ]); + let (content, reasoning, tool_calls, refusal) = convert_anthropic_blocks(&blocks).unwrap(); + assert_eq!(content.as_deref(), Some("hello")); + assert!( + reasoning.is_none(), + "redacted_thinking has no readable text" + ); + assert!(tool_calls.is_none()); + assert!(refusal.is_none()); + } + + #[test] + fn test_convert_redacted_thinking_with_tool_use_unreadable_position() { + // redacted_thinking → text → tool_use + // Redacted (encrypted) thinking carries no readable text, so it + // cannot contribute reasoning segments. This is an explicit lossy + // degradation: the call is converted, but the encrypted reasoning + // itself is not representable in OpenAI format (review §4.3). + let blocks = make_blocks(vec![ + ("redacted_thinking", serde_json::json!("encrypted")), + ("text", serde_json::json!("result")), + ( + "tool_use", + serde_json::json!({"id": "t1", "name": "fn", "input": {}}), + ), + ]); + let (content, reasoning, tool_calls, _) = convert_anthropic_blocks(&blocks).unwrap(); + assert_eq!(content.as_deref(), Some("result")); + // No readable reasoning → reasoning_content stays None. The + // position of the redacted block is NOT preserved (documented + // degradation). + assert!( + reasoning.is_none(), + "redacted_thinking alone must not fabricate reasoning segments" + ); + let tc = tool_calls.expect("should have tool_calls"); + assert_eq!(tc.len(), 1); + assert_eq!(tc[0]["id"], "t1"); + } + + #[test] + fn test_convert_refusal_preserved() { + let blocks = make_blocks(vec![ + ("thinking", serde_json::json!("should I refuse?")), + ("refusal", serde_json::json!("I cannot help with that.")), + ]); + let (content, reasoning, tool_calls, refusal) = convert_anthropic_blocks(&blocks).unwrap(); + assert!(content.is_none(), "no text blocks → content is None"); + assert!(reasoning.is_some(), "thinking should produce reasoning"); + assert!(tool_calls.is_none()); + assert_eq!(refusal.as_deref(), Some("I cannot help with that.")); + } + + #[test] + fn test_convert_multiple_text_blocks_concatenated() { + let blocks = make_blocks(vec![ + ("thinking", serde_json::json!("hmm")), + ("text", serde_json::json!("part1 ")), + ("text", serde_json::json!("part2")), + ]); + let (content, _, _, _) = convert_anthropic_blocks(&blocks).unwrap(); + assert_eq!(content.as_deref(), Some("part1 part2")); + } + + #[test] + fn test_convert_multiple_thinking_blocks_joined() { + let blocks = make_blocks(vec![ + ("thinking", serde_json::json!("think A")), + ("thinking", serde_json::json!("think B")), + ("text", serde_json::json!("answer")), + ]); + let (content, reasoning, _, _) = convert_anthropic_blocks(&blocks).unwrap(); + assert_eq!(content.as_deref(), Some("answer")); + let (_, segs) = reasoning.expect("should have reasoning"); + assert_eq!( + segs, + vec!["think A\nthink B"], + "multiple thinking blocks should be joined with newline" + ); + } + + #[test] + fn test_convert_empty_blocks() { + let blocks: Vec = vec![]; + let (content, reasoning, tool_calls, refusal) = convert_anthropic_blocks(&blocks).unwrap(); + assert!(content.is_none()); + assert!(reasoning.is_none()); + assert!(tool_calls.is_none()); + assert!(refusal.is_none()); + } + + #[test] + fn test_convert_tool_use_arguments_serialized() { + let blocks = make_blocks(vec![( + "tool_use", + serde_json::json!({ + "id": "call_abc", + "name": "search", + "input": {"query": "hello world", "limit": 10} + }), + )]); + let (_, _, tool_calls, _) = convert_anthropic_blocks(&blocks).unwrap(); + let tc = tool_calls.expect("should have tool_calls"); + let args: serde_json::Value = + serde_json::from_str(tc[0]["function"]["arguments"].as_str().unwrap()).unwrap(); + assert_eq!(args["query"], "hello world"); + assert_eq!(args["limit"], 10); + } + + #[test] + fn test_convert_trailing_thinking_after_last_tool_use() { + // tool_use → thinking (trailing, no more tool calls) + let blocks = make_blocks(vec![ + ( + "tool_use", + serde_json::json!({"id": "t1", "name": "fn", "input": {}}), + ), + ("thinking", serde_json::json!("trailing thought")), + ]); + let (content, reasoning, tool_calls, _) = convert_anthropic_blocks(&blocks).unwrap(); + assert!(content.is_none()); + let (is_segments, segs) = reasoning.expect("should have reasoning"); + assert!(is_segments); + // segments[0] = "" (before tool_use), segments[1] = "trailing thought" + assert_eq!(segs.len(), 2); + assert_eq!(segs[0], ""); + assert_eq!(segs[1], "trailing thought"); + assert_eq!(tool_calls.unwrap().len(), 1); + } + + #[test] + fn test_convert_rejects_tool_use_missing_id() { + let blocks = make_blocks(vec![( + "tool_use", + serde_json::json!({"name": "fn", "input": {}}), + )]); + let err = convert_anthropic_blocks(&blocks).unwrap_err(); + assert!(err.contains("missing a non-empty 'id'"), "got: {err}"); + } + + #[test] + fn test_convert_rejects_tool_use_missing_name() { + let blocks = make_blocks(vec![( + "tool_use", + serde_json::json!({"id": "t1", "input": {}}), + )]); + let err = convert_anthropic_blocks(&blocks).unwrap_err(); + assert!(err.contains("missing a non-empty 'name'"), "got: {err}"); + } + + #[test] + fn test_convert_rejects_duplicate_tool_use_ids() { + let blocks = make_blocks(vec![ + ( + "tool_use", + serde_json::json!({"id": "same", "name": "a", "input": {}}), + ), + ( + "tool_use", + serde_json::json!({"id": "same", "name": "b", "input": {}}), + ), + ]); + let err = convert_anthropic_blocks(&blocks).unwrap_err(); + assert!(err.contains("duplicate tool_use id"), "got: {err}"); + } + + #[test] + fn test_convert_rejects_empty_tool_use_id() { + let blocks = make_blocks(vec![( + "tool_use", + serde_json::json!({"id": "", "name": "fn", "input": {}}), + )]); + assert!(convert_anthropic_blocks(&blocks).is_err()); + } + + // ----------------------------------------------------------------------- + // Strict-validation tests (review F2/F3/F4) + // ----------------------------------------------------------------------- + + #[test] + fn test_convert_rejects_tool_use_missing_input() { + let blocks = vec![serde_json::json!({"type": "tool_use", "id": "t1", "name": "fn"})]; + let err = convert_anthropic_blocks(&blocks).unwrap_err(); + assert!(err.contains("missing an 'input' field"), "got: {err}"); + } + + #[test] + fn test_convert_rejects_tool_use_non_object_input() { + for bad in [ + serde_json::json!("string"), + serde_json::json!(123), + serde_json::json!(null), + serde_json::json!([1, 2]), + ] { + let blocks = vec![ + serde_json::json!({"type": "tool_use", "id": "t1", "name": "fn", "input": bad}), + ]; + let err = convert_anthropic_blocks(&blocks).unwrap_err(); + assert!(err.contains("'input' must be an object"), "got: {err}"); + } + } + + #[test] + fn test_convert_rejects_unknown_block_type() { + // A future Anthropic block type must be rejected, not silently + // deleted when the content array is replaced (review F2). + let blocks = vec![ + serde_json::json!({"type": "thinking", "thinking": "hmm"}), + serde_json::json!({"type": "future_block", "data": "keep me"}), + ]; + let err = convert_anthropic_blocks(&blocks).unwrap_err(); + assert!(err.contains("unsupported content block type"), "got: {err}"); + } + + #[test] + fn test_convert_rejects_server_tool_use_block() { + // server_tool_use has no OpenAI representation; it must not fall + // into a silent-ignore branch either (review F2). + let blocks = vec![ + serde_json::json!({"type": "server_tool_use", "id": "srv1", "name": "web_search", "input": {}}), + ]; + let err = convert_anthropic_blocks(&blocks).unwrap_err(); + assert!(err.contains("unsupported content block type"), "got: {err}"); + } + + #[test] + fn test_convert_rejects_text_missing_or_typed_payload() { + for bad in [ + serde_json::json!({"type": "text"}), + serde_json::json!({"type": "text", "text": 123}), + serde_json::json!({"type": "text", "text": null}), + ] { + let err = convert_anthropic_blocks(&[bad]).unwrap_err(); + assert!(err.contains("missing a string 'text' field"), "got: {err}"); + } + } + + #[test] + fn test_convert_rejects_thinking_missing_payload() { + let blocks = vec![serde_json::json!({"type": "thinking", "signature": "sig"})]; + let err = convert_anthropic_blocks(&blocks).unwrap_err(); + assert!( + err.contains("missing a string 'thinking' field"), + "got: {err}" + ); + } + + #[test] + fn test_convert_rejects_refusal_missing_payload() { + let blocks = vec![serde_json::json!({"type": "refusal"})]; + let err = convert_anthropic_blocks(&blocks).unwrap_err(); + assert!( + err.contains("missing a string 'refusal' field"), + "got: {err}" + ); + } + + #[test] + fn test_convert_multiple_refusals_merged() { + // Earlier refusal blocks must not be silently dropped (review F4). + let blocks = make_blocks(vec![ + ("thinking", serde_json::json!("should I?")), + ("refusal", serde_json::json!("first")), + ("refusal", serde_json::json!("last")), + ]); + let (_, _, _, refusal) = convert_anthropic_blocks(&blocks).unwrap(); + assert_eq!(refusal.as_deref(), Some("first\nlast")); + } + + // ----------------------------------------------------------------------- + // Mixed-format conflict policy tests (review F1) + // ----------------------------------------------------------------------- + + #[test] + fn test_mixed_policy_rejects_existing_calls_plus_new_calls() { + // R1: old tool_calls and residual tool_use cannot be ordered. + let err = check_mixed_format_conflicts(true, false, true, false).unwrap_err(); + assert!(err.contains("tool_calls"), "got: {err}"); + } + + #[test] + fn test_mixed_policy_rejects_existing_calls_plus_residual_thinking() { + // F1 case 1: gateway stripped the tool blocks but left thinking; + // merging A\nB loses that B followed t1. + let err = check_mixed_format_conflicts(true, false, false, true).unwrap_err(); + assert!(err.contains("tool_calls"), "got: {err}"); + assert!(err.contains("thinking"), "got: {err}"); + } + + #[test] + fn test_mixed_policy_rejects_existing_reasoning_plus_residual_thinking() { + // R2: existing reasoning_content and new thinking cannot be merged. + let err = check_mixed_format_conflicts(false, true, false, true).unwrap_err(); + assert!(err.contains("reasoning_content"), "got: {err}"); + } + + #[test] + fn test_mixed_policy_rejects_existing_reasoning_plus_new_calls() { + // F1 case 2: new calls cannot be positioned within existing + // reasoning segments; matching counts do not prove positions. + let err = check_mixed_format_conflicts(false, true, true, false).unwrap_err(); + assert!(err.contains("reasoning_content"), "got: {err}"); + assert!(err.contains("tool_use"), "got: {err}"); + } + + #[test] + fn test_mixed_policy_allows_text_only_residual_with_existing_fields() { + // Text (and refusal) merge cleanly; no calls/reasoning produced. + assert!(check_mixed_format_conflicts(true, false, false, false).is_ok()); + assert!(check_mixed_format_conflicts(false, true, false, false).is_ok()); + assert!(check_mixed_format_conflicts(true, true, false, false).is_ok()); + } + + #[test] + fn test_mixed_policy_allows_pure_blocks_without_existing_fields() { + // The normal compat path: no pre-existing OpenAI fields, anything + // the converter produced is safe to apply. + assert!(check_mixed_format_conflicts(false, false, true, true).is_ok()); + assert!(check_mixed_format_conflicts(false, false, false, true).is_ok()); + assert!(check_mixed_format_conflicts(false, false, true, false).is_ok()); + assert!(check_mixed_format_conflicts(false, false, false, false).is_ok()); + } + + // ----------------------------------------------------------------------- + // Middleware integration tests: real HTTP server + JSON echo downstream + // ----------------------------------------------------------------------- + + /// Spin up a throwaway HTTP server running only the compat middleware in + /// front of a JSON echo handler, and return its chat-completions URL. + /// The echo handler returns the exact body bytes it received, so tests + /// observe precisely what the middleware forwarded downstream. + async fn spawn_compat_echo_server() -> String { + async fn echo(body: Bytes) -> Response { + Response::builder() + .header(axum::http::header::CONTENT_TYPE, "application/json") + .body(Body::from(body)) + .expect("static response builder must succeed") + } + let app = Router::new() + .route("/v1/chat/completions", post(echo)) + .layer(middleware::from_fn(anthropic_content_compat_middleware)); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind ephemeral port"); + let addr = listener.local_addr().expect("local addr"); + tokio::spawn(async move { + axum::serve(listener, app).await.expect("echo server"); + }); + format!("http://{addr}/v1/chat/completions") + } + + async fn post_json(url: &str, payload: &serde_json::Value) -> reqwest::Response { + reqwest::Client::new() + .post(url) + .json(payload) + .send() + .await + .expect("POST to echo server") + } + + #[tokio::test] + async fn test_middleware_converts_residual_blocks_end_to_end() { + let url = spawn_compat_echo_server().await; + let payload = serde_json::json!({ + "model": "glm-5.3", + "messages": [ + {"role": "user", "content": "weather?"}, + {"role": "assistant", "content": [ + {"type": "thinking", "thinking": "thinking A"}, + {"type": "text", "text": "checking the weather"}, + {"type": "tool_use", "id": "t1", "name": "get_weather", "input": {"city": "Beijing"}}, + {"type": "thinking", "thinking": "thinking B"} + ]}, + {"role": "tool", "tool_call_id": "t1", "content": "sunny"} + ] + }); + let resp = post_json(&url, &payload).await; + assert_eq!(resp.status(), StatusCode::OK); + let echoed: serde_json::Value = resp.json().await.expect("echo json"); + let msg = &echoed["messages"][1]; + assert_eq!(msg["content"], serde_json::json!("checking the weather")); + // segments[i] precedes tool_calls[i]; trailing segment is last. + assert_eq!( + msg["reasoning_content"], + serde_json::json!(["thinking A", "thinking B"]) + ); + assert_eq!(msg["tool_calls"][0]["id"], serde_json::json!("t1")); + assert_eq!(msg["tool_calls"][0]["type"], serde_json::json!("function")); + assert_eq!( + msg["tool_calls"][0]["function"]["name"], + serde_json::json!("get_weather") + ); + assert_eq!( + msg["tool_calls"][0]["function"]["arguments"], + serde_json::json!("{\"city\":\"Beijing\"}") + ); + // Non-assistant messages must pass through untouched. + assert_eq!(echoed["messages"][0], payload["messages"][0]); + assert_eq!(echoed["messages"][2], payload["messages"][2]); + } + + #[tokio::test] + async fn test_middleware_converts_user_tool_results_in_order_and_strips_cache_control() { + let url = spawn_compat_echo_server().await; + let payload = serde_json::json!({ + "model": "m", + "messages": [ + {"role": "assistant", "tool_calls": [ + {"id": "t1", "type": "function", "function": {"name": "one", "arguments": "{}"}}, + {"id": "t2", "type": "function", "function": {"name": "two", "arguments": "{}"}} + ]}, + {"role": "user", "content": [ + {"type": "text", "text": "before", "cache_control": {"type": "ephemeral"}}, + {"type": "tool_result", "tool_use_id": "t1", "content": "first result"}, + {"type": "tool_result", "tool_use_id": "t2", "is_error": true, + "content": [{"type": "text", "text": "second result"}]}, + {"type": "text", "text": "after", "cache_control": {"type": "ephemeral"}} + ]} + ] + }); + + let resp = post_json(&url, &payload).await; + assert_eq!(resp.status(), StatusCode::OK); + let echoed: serde_json::Value = resp.json().await.expect("echo json"); + let messages = echoed["messages"].as_array().expect("messages array"); + assert_eq!(messages.len(), 5); + assert_eq!(messages[1]["role"], "user"); + assert_eq!(messages[1]["content"][0]["text"], "before"); + assert!(messages[1]["content"][0].get("cache_control").is_none()); + assert_eq!(messages[2]["role"], "tool"); + assert_eq!(messages[2]["tool_call_id"], "t1"); + assert_eq!(messages[2]["content"], "first result"); + assert_eq!(messages[3]["role"], "tool"); + assert_eq!(messages[3]["tool_call_id"], "t2"); + assert_eq!( + messages[3]["content"], + serde_json::json!([ + {"type": "text", "text": "[tool error]"}, + {"type": "text", "text": "second result"} + ]) + ); + assert_eq!(messages[4]["role"], "user"); + assert_eq!(messages[4]["content"][0]["text"], "after"); + assert!(messages[4]["content"][0].get("cache_control").is_none()); + } + + #[tokio::test] + async fn test_middleware_rejects_image_tool_results() { + let url = spawn_compat_echo_server().await; + let payload = serde_json::json!({ + "model": "m", + "messages": [{"role": "user", "content": [ + {"type": "tool_result", "tool_use_id": "t1", "content": [ + {"type": "image", "source": { + "type": "base64", "media_type": "image/png", "data": "aGVsbG8=" + }} + ]} + ]}] + }); + + let resp = post_json(&url, &payload).await; + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + let err: serde_json::Value = resp.json().await.expect("error json"); + let message = err["message"].as_str().unwrap_or_default(); + assert!(message.contains("image blocks in tool_result content are not supported")); + } + + #[tokio::test] + async fn test_middleware_rejects_unrepresentable_block_order_end_to_end() { + let url = spawn_compat_echo_server().await; + let cases = [ + ( + serde_json::json!([ + {"type": "tool_use", "id": "t1", "name": "fn", "input": {}}, + {"type": "text", "text": "after the call"} + ]), + "text after tool_use", + ), + ( + serde_json::json!([ + {"type": "text", "text": "before reasoning"}, + {"type": "thinking", "thinking": "late thought"} + ]), + "thinking after text before the first tool_use", + ), + ]; + for (blocks, expected_error) in cases { + let payload = serde_json::json!({ + "model": "m", + "messages": [{"role": "assistant", "content": blocks}] + }); + let resp = post_json(&url, &payload).await; + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + let err: serde_json::Value = resp.json().await.expect("error json"); + let message = err["message"].as_str().unwrap_or_default(); + assert!(message.contains(expected_error), "got: {message}"); + assert!(message.contains("block index 1"), "got: {message}"); + } + } + + #[tokio::test] + async fn test_middleware_normal_openai_request_untouched() { + let url = spawn_compat_echo_server().await; + let payload = serde_json::json!({ + "model": "m", + "messages": [ + {"role": "assistant", "content": "plain string"}, + {"role": "assistant", "content": [{"type": "text", "text": "openai part"}]}, + {"role": "user", "content": "hi"} + ] + }); + let resp = post_json(&url, &payload).await; + assert_eq!(resp.status(), StatusCode::OK); + let echoed: serde_json::Value = resp.json().await.expect("echo json"); + assert_eq!( + echoed, payload, + "pure OpenAI requests must be byte-equivalent" + ); + } + + #[tokio::test] + async fn test_middleware_invalid_json_passes_through() { + // Unparseable bodies are forwarded untouched so the downstream Json + // extractor produces the standard parse error; the compat middleware + // itself must not reject them. + let url = spawn_compat_echo_server().await; + let resp = reqwest::Client::new() + .post(&url) + .header("content-type", "application/json") + .body("{not json") + .send() + .await + .expect("POST to echo server"); + assert_eq!(resp.status(), StatusCode::OK); + let body = resp.text().await.expect("echo body"); + assert_eq!(body, "{not json"); + } + + #[tokio::test] + async fn test_middleware_rejects_mixed_existing_tool_calls_with_thinking() { + // Review F1 case 1: gateway translated the tool calls but left the + // thinking blocks; the reasoning position cannot be recovered. + let url = spawn_compat_echo_server().await; + let payload = serde_json::json!({ + "model": "m", + "messages": [ + {"role": "assistant", + "tool_calls": [{"id": "t1", "type": "function", + "function": {"name": "fn1", "arguments": "{}"}}], + "content": [ + {"type": "thinking", "thinking": "thinking A"}, + {"type": "thinking", "thinking": "thinking B"} + ]} + ] + }); + let resp = post_json(&url, &payload).await; + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + let err: serde_json::Value = resp.json().await.expect("error json"); + let msg = err["message"].as_str().unwrap_or_default(); + assert!(msg.contains("tool_calls"), "got: {msg}"); + assert!(msg.contains("thinking"), "got: {msg}"); + } + + #[tokio::test] + async fn test_middleware_rejects_mixed_existing_reasoning_with_tool_use() { + // Review F1 case 2: existing reasoning_content with residual + // tool_use; segment/call correspondence cannot be validated. + let url = spawn_compat_echo_server().await; + let payload = serde_json::json!({ + "model": "m", + "messages": [ + {"role": "assistant", + "reasoning_content": ["A", "B", ""], + "content": [ + {"type": "tool_use", "id": "t1", "name": "fn1", "input": {}} + ]} + ] + }); + let resp = post_json(&url, &payload).await; + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + let err: serde_json::Value = resp.json().await.expect("error json"); + let msg = err["message"].as_str().unwrap_or_default(); + assert!(msg.contains("reasoning_content"), "got: {msg}"); + assert!(msg.contains("tool_use"), "got: {msg}"); + } + + #[tokio::test] + async fn test_middleware_rejects_unknown_block_type_end_to_end() { + let url = spawn_compat_echo_server().await; + let payload = serde_json::json!({ + "model": "m", + "messages": [ + {"role": "assistant", "content": [ + {"type": "thinking", "thinking": "hmm"}, + {"type": "future_block", "data": "keep me"} + ]} + ] + }); + let resp = post_json(&url, &payload).await; + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + let err: serde_json::Value = resp.json().await.expect("error json"); + let msg = err["message"].as_str().unwrap_or_default(); + assert!(msg.contains("unsupported content block type"), "got: {msg}"); + } + + #[tokio::test] + async fn test_middleware_rejects_tool_use_missing_input_end_to_end() { + let url = spawn_compat_echo_server().await; + let payload = serde_json::json!({ + "model": "m", + "messages": [ + {"role": "assistant", "content": [ + {"type": "tool_use", "id": "t1", "name": "fn"}, + {"type": "text", "text": 123} + ]} + ] + }); + let resp = post_json(&url, &payload).await; + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + let err: serde_json::Value = resp.json().await.expect("error json"); + let msg = err["message"].as_str().unwrap_or_default(); + // Errors carry the messages[i].content[j] position: the assistant + // message is messages[0] here, the malformed block is content[0]. + assert!(msg.starts_with("messages[0]:"), "got: {msg}"); + assert!(msg.contains("missing an 'input' field"), "got: {msg}"); + assert!(msg.contains("block index 0"), "got: {msg}"); + } + + #[tokio::test] + async fn test_middleware_conversion_output_is_idempotent() { + // Feeding an already-converted message back through the middleware + // must be a no-op: string content is never re-inspected and the + // request is forwarded unchanged. + let url = spawn_compat_echo_server().await; + let payload = serde_json::json!({ + "model": "m", + "messages": [ + {"role": "assistant", + "content": "done", + "reasoning_content": ["thinking A", "thinking B"], + "tool_calls": [{"id": "t1", "type": "function", + "function": {"name": "get_weather", + "arguments": "{\"city\":\"Beijing\"}"}}]} + ] + }); + let resp = post_json(&url, &payload).await; + assert_eq!(resp.status(), StatusCode::OK); + let echoed: serde_json::Value = resp.json().await.expect("echo json"); + assert_eq!( + echoed, payload, + "converted output must pass through unchanged" + ); + } + + #[tokio::test] + #[serial_test::serial] + async fn test_middleware_oversized_body_returns_413() { + temp_env::async_with_vars([(env_llm::DYN_HTTP_BODY_LIMIT_MB, Some("1"))], async { + let url = spawn_compat_echo_server().await; + let big = "x".repeat(1024 * 1024 + 1); + let payload = + serde_json::json!({"model": "m", "messages": [{"role": "user", "content": big}]}); + let resp = post_json(&url, &payload).await; + assert_eq!(resp.status(), StatusCode::PAYLOAD_TOO_LARGE); + }) + .await; + } + + #[tokio::test] + async fn test_middleware_rejects_non_array_tool_calls() { + // Review v4 R1: a non-null, non-array `tool_calls` is a gateway + // format error and must be rejected — never silently overwritten + // by the converted calls. + let url = spawn_compat_echo_server().await; + for bad in [ + serde_json::json!({"id": "old", "function": {"name": "old_fn", "arguments": "{}"}}), + serde_json::json!("not-an-array"), + serde_json::json!(42), + serde_json::json!(true), + ] { + let payload = serde_json::json!({ + "model": "m", + "messages": [ + {"role": "assistant", + "tool_calls": bad, + "content": [ + {"type": "tool_use", "id": "t1", "name": "f", "input": {}} + ]} + ] + }); + let resp = post_json(&url, &payload).await; + assert_eq!( + resp.status(), + StatusCode::BAD_REQUEST, + "tool_calls={bad} must be rejected" + ); + let err: serde_json::Value = resp.json().await.expect("error json"); + let msg = err["message"].as_str().unwrap_or_default(); + assert!(msg.starts_with("messages[0].tool_calls"), "got: {msg}"); + assert!(msg.contains("must be an array or null"), "got: {msg}"); + } + } + + #[tokio::test] + async fn test_middleware_allows_null_and_empty_tool_calls() { + // Missing/null/empty-array tool_calls means "no existing calls": + // the residual tool_use must convert normally. + let url = spawn_compat_echo_server().await; + for existing in [serde_json::Value::Null, serde_json::json!([])] { + let payload = serde_json::json!({ + "model": "m", + "messages": [ + {"role": "assistant", + "tool_calls": existing, + "content": [ + {"type": "tool_use", "id": "t1", "name": "get_weather", "input": {"city": "Beijing"}} + ]} + ] + }); + let resp = post_json(&url, &payload).await; + assert_eq!(resp.status(), StatusCode::OK); + let echoed: serde_json::Value = resp.json().await.expect("echo json"); + assert_eq!( + echoed["messages"][0]["tool_calls"][0]["id"], + serde_json::json!("t1") + ); + } + } }