From c95a0f1cd4344e9c74ea192fe60c48e51e610b8f Mon Sep 17 00:00:00 2001 From: guojn1 Date: Sun, 20 Sep 2026 15:03:53 +0800 Subject: [PATCH 1/8] fix(openai): normalize Anthropic content blocks in chat completions requests Gateway may forward Anthropic-format content blocks (thinking, tool_use) inside assistant message content arrays to the OpenAI /v1/chat/completions endpoint. The OpenAI deserializer only accepts text and refusal part types, causing a 400 error. Add middleware to convert Anthropic blocks before deserialization: thinking -> reasoning_content, tool_use -> tool_calls. Only touches assistant messages whose content array contains type=thinking or type=tool_use. Standard OpenAI requests pass through unchanged. --- lib/llm/src/http/service/openai.rs | 714 +++++++++++++++++++++++++++++ 1 file changed, 714 insertions(+) diff --git a/lib/llm/src/http/service/openai.rs b/lib/llm/src/http/service/openai.rs index d176c4442ed6..4b4d7056245a 100644 --- a/lib/llm/src/http/service/openai.rs +++ b/lib/llm/src/http/service/openai.rs @@ -487,6 +487,420 @@ 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 `content` arrays. The OpenAI deserializer only accepts +// `text` and `refusal` part types, so `thinking`, `tool_use`, and +// `redacted_thinking` blocks cause a 400 "data did not match any variant of +// untagged enum ChatCompletionRequestAssistantMessageContent" error. +// +// This middleware inspects the raw JSON body *before* axum's `Json` extractor +// deserializes it. For every assistant message whose `content` is an array +// containing Anthropic block types, it converts: +// +// {type:"thinking", thinking:"…"} → reasoning_content (segments form) +// {type:"redacted_thinking", …} → preserved as reasoning segment placeholder +// {type:"tool_use", id,name,input} → tool_calls[] entry +// {type:"text", text:"…"} → content string +// {type:"refusal", refusal:"…"} → refusal field +// +// Interleaving order is preserved: reasoning segments are emitted one per +// tool-call position (segments.len() == tool_calls.len() + 1), matching +// the semantics of `convert_assistant_blocks` in the Anthropic protocol +// module. This is required for KV-cache correctness in multi-tool turns. + +/// 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 losslessly. +/// +/// `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; +/// - duplicate tool_use ids within the block list are rejected. +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(); + + for block in blocks { + let part_type = block.get("type").and_then(|t| t.as_str()).unwrap_or(""); + match part_type { + "text" => { + if let Some(t) = block.get("text").and_then(|t| t.as_str()) { + text_content.push_str(t); + } + } + "thinking" => { + if let Some(t) = block.get("thinking").and_then(|t| t.as_str()) { + if !pending_reasoning.is_empty() { + pending_reasoning.push('\n'); + } + pending_reasoning.push_str(t); + } + } + "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" => { + // 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 '{id}' within assistant content blocks" + )); + } + + let input = block.get("input").cloned().unwrap_or(serde_json::Value::Null); + let arguments = serde_json::to_string(&input).unwrap_or_default(); + tool_calls.push(serde_json::json!({ + "id": id, + "type": "function", + "function": { + "name": name, + "arguments": arguments, + } + })); + } + "refusal" => { + if let Some(r) = block.get("refusal").and_then(|r| r.as_str()) { + refusal_content = Some(r.to_string()); + } + } + _ => {} + } + } + + // 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)) +} + + +/// 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() +} + +/// 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 messages with Anthropic content blocks. + let modified = if let Some(messages) = json + .get_mut("messages") + .and_then(|m| m.as_array_mut()) + { + let mut changed = false; + for msg in messages.iter_mut() { + // 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). + let (text, reasoning_segments, tool_calls, refusal) = + match convert_anthropic_blocks(&blocks) { + Ok(v) => v, + Err(e) => { + return anthropic_compat_reject(body_bytes.len(), e); + } + }; + + let obj = match msg.as_object_mut() { + Some(o) => o, + None => continue, + }; + + // R1: a message that already carries OpenAI tool_calls while + // still containing residual Anthropic blocks cannot have its + // call ordering proven. Guessing (old-first / new-first) + // corrupts reasoning-segment positions, so reject explicitly. + if let Some(tc) = &tool_calls { + let has_existing_calls = obj + .get("tool_calls") + .map(|v| { + !v.is_null() && v.as_array().map(|a| !a.is_empty()).unwrap_or(false) + }) + .unwrap_or(false); + if has_existing_calls && !tc.is_empty() { + return anthropic_compat_reject( + body_bytes.len(), + "assistant message mixes an existing tool_calls array with \ + residual Anthropic content blocks; the original call order \ + cannot be determined, ask the gateway to fully translate \ + tool calls before forwarding" + .to_string(), + ); + } + } + + // R2: an existing non-null reasoning_content together with + // residual thinking blocks cannot be merged without losing + // position semantics; reject instead of overwriting. + if reasoning_segments.is_some() + && obj + .get("reasoning_content") + .map(|v| !v.is_null()) + .unwrap_or(false) + { + return anthropic_compat_reject( + body_bytes.len(), + "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(), + ); + } + + // 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; + } + 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 +2973,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 +6261,303 @@ 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![ + ("tool_use", serde_json::json!({"id": "t1", "name": "get_weather", "input": {"city": "Beijing"}})), + ("text", serde_json::json!("It's sunny.")), + ]); + 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_interleaved_thinking_tool_use_preserves_order() { + // thinking A → tool_use 1 → thinking B → tool_use 2 → text + let blocks = make_blocks(vec![ + ("thinking", serde_json::json!("thinking A")), + ("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": {}})), + ("text", serde_json::json!("done")), + ]); + 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 → tool_use → text + // 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")), + ("tool_use", serde_json::json!({"id": "t1", "name": "fn", "input": {}})), + ("text", serde_json::json!("result")), + ]); + 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![ + ("text", serde_json::json!("part1 ")), + ("thinking", serde_json::json!("hmm")), + ("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")), + ("text", serde_json::json!("final answer")), + ]); + let (content, reasoning, tool_calls, _) = convert_anthropic_blocks(&blocks).unwrap(); + assert_eq!(content.as_deref(), Some("final answer")); + 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 'same'"), "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()); + } + } From eca70f890046d496d246e9c6eb8df507c7cef873 Mon Sep 17 00:00:00 2001 From: guojn1 Date: Tue, 22 Sep 2026 20:42:37 +0800 Subject: [PATCH 2/8] fix(openai): reject unprovable mixed formats and malformed Anthropic blocks Address PR review findings on the Anthropic content-block compat middleware: - F1: reject cross combinations of partially-translated messages whose ordering cannot be proven: existing tool_calls with residual thinking, and existing reasoning_content with residual tool_use (unified into check_mixed_format_conflicts, replacing the R1/R2 pair) - F2: reject unknown/unsupported block types (e.g. server_tool_use) instead of silently dropping them when the content array is replaced - F3: require tool_use input to be present and an object; require string payloads for text/thinking/refusal blocks, with the block index reported in the error - F4: merge multiple refusal blocks with a newline instead of keeping only the last one - tests: 14 new unit tests for the converter and the conflict policy, plus 9 middleware integration tests against a live echo server (end-to-end conversion, pure-OpenAI passthrough, invalid-JSON passthrough, F1/F2/F3 rejections, idempotency, 413 body limit) --- lib/llm/src/http/service/openai.rs | 626 ++++++++++++++++++++++++++--- 1 file changed, 574 insertions(+), 52 deletions(-) diff --git a/lib/llm/src/http/service/openai.rs b/lib/llm/src/http/service/openai.rs index 4b4d7056245a..55db1253fa57 100644 --- a/lib/llm/src/http/service/openai.rs +++ b/lib/llm/src/http/service/openai.rs @@ -502,7 +502,8 @@ pub async fn smart_json_error_middleware(request: Request, next: Next) -> // containing Anthropic block types, it converts: // // {type:"thinking", thinking:"…"} → reasoning_content (segments form) -// {type:"redacted_thinking", …} → preserved as reasoning segment placeholder +// {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 @@ -511,6 +512,12 @@ pub async fn smart_json_error_middleware(request: Request, next: Next) -> // tool-call position (segments.len() == tool_calls.len() + 1), matching // the semantics of `convert_assistant_blocks` in the Anthropic protocol // module. This is required for KV-cache correctness in multi-tool turns. +// +// Anything that cannot be converted losslessly is rejected with 400 rather +// than guessed: 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). /// Anthropic content-block types that trigger conversion. const ANTHROPIC_BLOCK_TYPES: &[&str] = &["thinking", "tool_use", "redacted_thinking"]; @@ -542,7 +549,14 @@ fn has_anthropic_content_blocks(content: &serde_json::Value) -> bool { /// /// Strict validation (rejects instead of guessing): /// - tool_use blocks missing `id` or `name` are rejected; -/// - duplicate tool_use ids within the block list 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. fn convert_anthropic_blocks( blocks: &[serde_json::Value], ) -> Result< @@ -561,20 +575,32 @@ fn convert_anthropic_blocks( let mut pending_reasoning = String::new(); let mut seen_ids: std::collections::HashSet = std::collections::HashSet::new(); - for block in blocks { + 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" => { - if let Some(t) = block.get("text").and_then(|t| t.as_str()) { - text_content.push_str(t); + match block.get("text").and_then(|t| t.as_str()) { + Some(t) => text_content.push_str(t), + None => { + return Err(format!( + "text block is missing a string 'text' field (block index {block_idx})" + )); + } } } "thinking" => { - if let Some(t) = block.get("thinking").and_then(|t| t.as_str()) { - if !pending_reasoning.is_empty() { - pending_reasoning.push('\n'); + match block.get("thinking").and_then(|t| t.as_str()) { + Some(t) => { + 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})" + )); } - pending_reasoning.push_str(t); } } "redacted_thinking" => { @@ -614,7 +640,21 @@ fn convert_anthropic_blocks( )); } - let input = block.get("input").cloned().unwrap_or(serde_json::Value::Null); + // 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).unwrap_or_default(); tool_calls.push(serde_json::json!({ "id": id, @@ -626,11 +666,35 @@ fn convert_anthropic_blocks( })); } "refusal" => { - if let Some(r) = block.get("refusal").and_then(|r| r.as_str()) { - refusal_content = Some(r.to_string()); + 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. + other => { + return Err(format!( + "unsupported content block type '{other}' in Anthropic block array (block index {block_idx}); \ + only text/thinking/redacted_thinking/tool_use/refusal can be converted" + )); + } } } @@ -679,6 +743,74 @@ fn convert_anthropic_blocks( } +/// 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 @@ -752,7 +884,7 @@ pub async fn anthropic_content_compat_middleware( .and_then(|m| m.as_array_mut()) { let mut changed = false; - for msg in messages.iter_mut() { + for (msg_idx, msg) in messages.iter_mut().enumerate() { // Only process assistant messages. let is_assistant = msg .get("role") @@ -775,11 +907,16 @@ pub async fn anthropic_content_compat_middleware( // 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(), e); + return anthropic_compat_reject( + body_bytes.len(), + format!("messages[{msg_idx}]: {e}"), + ); } }; @@ -788,45 +925,34 @@ pub async fn anthropic_content_compat_middleware( None => continue, }; - // R1: a message that already carries OpenAI tool_calls while - // still containing residual Anthropic blocks cannot have its - // call ordering proven. Guessing (old-first / new-first) - // corrupts reasoning-segment positions, so reject explicitly. - if let Some(tc) = &tool_calls { - let has_existing_calls = obj - .get("tool_calls") - .map(|v| { - !v.is_null() && v.as_array().map(|a| !a.is_empty()).unwrap_or(false) - }) - .unwrap_or(false); - if has_existing_calls && !tc.is_empty() { - return anthropic_compat_reject( - body_bytes.len(), - "assistant message mixes an existing tool_calls array with \ - residual Anthropic content blocks; the original call order \ - cannot be determined, ask the gateway to fully translate \ - tool calls before forwarding" - .to_string(), - ); - } - } - - // R2: an existing non-null reasoning_content together with - // residual thinking blocks cannot be merged without losing - // position semantics; reject instead of overwriting. - if reasoning_segments.is_some() - && obj - .get("reasoning_content") - .map(|v| !v.is_null()) - .unwrap_or(false) - { + // 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. + let has_existing_calls = obj + .get("tool_calls") + .map(|v| { + !v.is_null() && v.as_array().map(|a| !a.is_empty()).unwrap_or(false) + }) + .unwrap_or(false); + 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(), - "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(), + format!("messages[{msg_idx}]: {e}"), ); } @@ -6560,4 +6686,400 @@ mod tests { 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 'future_block'"), + "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 'server_tool_use'"), + "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": "tool_use", "id": "t1", "name": "get_weather", "input": {"city": "Beijing"}}, + {"type": "thinking", "thinking": "thinking B"}, + {"type": "text", "text": "done"} + ]}, + {"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!("done")); + // 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_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; + } + } From bc6a19eae74b58fa0fab6d807b5e80d5e1c4e495 Mon Sep 17 00:00:00 2001 From: guojn1 Date: Wed, 23 Sep 2026 15:53:30 +0800 Subject: [PATCH 3/8] fix(openai): reject non-array tool_calls instead of silently overwriting it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review v4 R1 (P2): a non-null, non-array tool_calls value (object, string, number, bool) on an assistant message with residual Anthropic blocks was treated as 'no existing calls' by the mixed-format check, so the converted tool_calls insert silently deleted the original field — normalizing a gateway format error into a valid-looking request. Distinguish the structural type up front: missing/null/empty array means no existing calls, a non-empty array enters the conflict policy, anything else is rejected with 400 naming messages[i].tool_calls. Route regression tests: object/string/number/bool all return 400; null and empty array still convert; non-empty legal arrays keep the existing mixed-format conflict behavior. --- lib/llm/src/http/service/openai.rs | 86 +++++++++++++++++++++++++++--- 1 file changed, 80 insertions(+), 6 deletions(-) diff --git a/lib/llm/src/http/service/openai.rs b/lib/llm/src/http/service/openai.rs index 55db1253fa57..c41ce14168d8 100644 --- a/lib/llm/src/http/service/openai.rs +++ b/lib/llm/src/http/service/openai.rs @@ -932,12 +932,22 @@ pub async fn anthropic_content_compat_middleware( // instead of guessed — including the two cross combinations: // existing tool_calls with residual thinking, and existing // reasoning_content with residual tool_use. - let has_existing_calls = obj - .get("tool_calls") - .map(|v| { - !v.is_null() && v.as_array().map(|a| !a.is_empty()).unwrap_or(false) - }) - .unwrap_or(false); + // + // 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()) @@ -7082,4 +7092,68 @@ mod tests { .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") + ); + } + } + } From d0dc628674a993f3b4bef48573343c3cf3ad857c Mon Sep 17 00:00:00 2001 From: guojn1 Date: Tue, 29 Sep 2026 15:51:04 +0800 Subject: [PATCH 4/8] fix(openai): reject unrepresentable block ordering in Anthropic compat converter - Reject text after tool_use: OpenAI content field cannot preserve post-call text ordering - Reject thinking after text before first tool_use: flat reasoning path would lose ordering - Stop swallowing serde_json::to_string errors on tool_use arguments - Remove user input (id/type) from error messages, use block index - rustfmt formatting pass --- lib/llm/src/http/service/openai.rs | 307 ++++++++++++++++------------- 1 file changed, 173 insertions(+), 134 deletions(-) diff --git a/lib/llm/src/http/service/openai.rs b/lib/llm/src/http/service/openai.rs index c41ce14168d8..e723d9501b85 100644 --- a/lib/llm/src/http/service/openai.rs +++ b/lib/llm/src/http/service/openai.rs @@ -508,16 +508,19 @@ pub async fn smart_json_error_middleware(request: Request, next: Next) -> // {type:"text", text:"…"} → content string // {type:"refusal", refusal:"…"} → refusal field // -// Interleaving order is preserved: reasoning segments are emitted one per -// tool-call position (segments.len() == tool_calls.len() + 1), matching -// the semantics of `convert_assistant_blocks` in the Anthropic protocol -// module. This is required for KV-cache correctness in multi-tool turns. +// 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. // -// Anything that cannot be converted losslessly is rejected with 400 rather -// than guessed: 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). +// 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"]; @@ -557,6 +560,8 @@ fn has_anthropic_content_blocks(content: &serde_json::Value) -> bool { /// - 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< @@ -574,35 +579,46 @@ fn convert_anthropic_blocks( 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) => text_content.push_str(t), - None => { + "text" => match block.get("text").and_then(|t| t.as_str()) { + Some(t) => { + if seen_tool_use && !t.is_empty() { return Err(format!( - "text block is missing a string 'text' field (block index {block_idx})" + "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(); } - } - "thinking" => { - match block.get("thinking").and_then(|t| t.as_str()) { - Some(t) => { - if !pending_reasoning.is_empty() { - pending_reasoning.push('\n'); - } - pending_reasoning.push_str(t); - } - None => { + 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 block is missing a string 'thinking' field (block index {block_idx})" + "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 @@ -610,6 +626,7 @@ fn convert_anthropic_blocks( // 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)); @@ -620,9 +637,7 @@ fn convert_anthropic_blocks( .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() - })? + .ok_or_else(|| "tool_use block is missing a non-empty 'id' field".to_string())? .to_string(); let name = block .get("name") @@ -636,7 +651,7 @@ fn convert_anthropic_blocks( // Reject duplicate ids among the new blocks themselves (R3). if !seen_ids.insert(id.clone()) { return Err(format!( - "duplicate tool_use id '{id}' within assistant content blocks" + "duplicate tool_use id within assistant content blocks (block index {block_idx})" )); } @@ -647,7 +662,9 @@ fn convert_anthropic_blocks( let input = block .get("input") .ok_or_else(|| { - format!("tool_use block is missing an 'input' field (block index {block_idx})") + format!( + "tool_use block is missing an 'input' field (block index {block_idx})" + ) })? .clone(); if !input.is_object() { @@ -655,7 +672,9 @@ fn convert_anthropic_blocks( "tool_use 'input' must be an object (block index {block_idx})" )); } - let arguments = serde_json::to_string(&input).unwrap_or_default(); + 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", @@ -689,9 +708,9 @@ fn convert_anthropic_blocks( // Anthropic types) are rejected explicitly. Silently ignoring // them here would delete data while reporting success, because // the whole content array is replaced below. - other => { + _ => { return Err(format!( - "unsupported content block type '{other}' in Anthropic block array (block index {block_idx}); \ + "unsupported content block type in Anthropic block array (block index {block_idx}); \ only text/thinking/redacted_thinking/tool_use/refusal can be converted" )); } @@ -715,23 +734,22 @@ fn convert_anthropic_blocks( // `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 + let reasoning_segments: Option<(bool, Vec)> = + if !tool_calls.is_empty() && segments.iter().any(|s| !s.is_empty()) { + Some((true, segments)) } else { - Some((false, vec![flat])) - } - }; + 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 @@ -742,7 +760,6 @@ fn convert_anthropic_blocks( 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 @@ -773,13 +790,11 @@ fn check_mixed_format_conflicts( produced_reasoning: bool, ) -> Result<(), String> { if has_existing_calls && produced_calls { - return Err( - "assistant message mixes an existing tool_calls array with \ + 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(), - ); + .to_string()); } if has_existing_calls && produced_reasoning { return Err( @@ -791,22 +806,18 @@ fn check_mixed_format_conflicts( ); } if has_existing_reasoning && produced_reasoning { - return Err( - "assistant message already has reasoning_content while its \ + 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(), - ); + .to_string()); } if has_existing_reasoning && produced_calls { - return Err( - "assistant message already has reasoning_content while its \ + 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(), - ); + .to_string()); } Ok(()) } @@ -837,10 +848,7 @@ fn anthropic_compat_reject(byte_len: usize, message: String) -> Response { /// 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 { +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 { @@ -879,10 +887,7 @@ pub async fn anthropic_content_compat_middleware( }; // Walk messages, convert assistant messages with Anthropic content blocks. - let modified = if let Some(messages) = json - .get_mut("messages") - .and_then(|m| m.as_array_mut()) - { + 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. @@ -6493,8 +6498,11 @@ mod tests { #[test] fn test_convert_tool_use_basic() { let blocks = make_blocks(vec![ - ("tool_use", serde_json::json!({"id": "t1", "name": "get_weather", "input": {"city": "Beijing"}})), ("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.")); @@ -6504,28 +6512,44 @@ mod tests { 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_eq!( + tc[0]["function"]["arguments"], + serde_json::json!({"city":"Beijing"}).to_string() + ); assert!(refusal.is_none()); } #[test] fn test_convert_interleaved_thinking_tool_use_preserves_order() { - // thinking A → tool_use 1 → thinking B → tool_use 2 → text + // thinking A → text → tool_use 1 → thinking B → tool_use 2 let blocks = make_blocks(vec![ ("thinking", serde_json::json!("thinking A")), - ("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": {}})), ("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"); + 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.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"); @@ -6545,22 +6569,28 @@ mod tests { ]); 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!( + 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 → tool_use → text + // 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")), - ("tool_use", serde_json::json!({"id": "t1", "name": "fn", "input": {}})), ("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")); @@ -6592,8 +6622,8 @@ mod tests { #[test] fn test_convert_multiple_text_blocks_concatenated() { let blocks = make_blocks(vec![ - ("text", serde_json::json!("part1 ")), ("thinking", serde_json::json!("hmm")), + ("text", serde_json::json!("part1 ")), ("text", serde_json::json!("part2")), ]); let (content, _, _, _) = convert_anthropic_blocks(&blocks).unwrap(); @@ -6610,7 +6640,11 @@ mod tests { 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"); + assert_eq!( + segs, + vec!["think A\nthink B"], + "multiple thinking blocks should be joined with newline" + ); } #[test] @@ -6625,18 +6659,18 @@ mod tests { #[test] fn test_convert_tool_use_arguments_serialized() { - let blocks = make_blocks(vec![ - ("tool_use", serde_json::json!({ + 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(); + 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); } @@ -6645,12 +6679,14 @@ mod tests { 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": {}})), + ( + "tool_use", + serde_json::json!({"id": "t1", "name": "fn", "input": {}}), + ), ("thinking", serde_json::json!("trailing thought")), - ("text", serde_json::json!("final answer")), ]); let (content, reasoning, tool_calls, _) = convert_anthropic_blocks(&blocks).unwrap(); - assert_eq!(content.as_deref(), Some("final answer")); + 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" @@ -6662,18 +6698,20 @@ mod tests { #[test] fn test_convert_rejects_tool_use_missing_id() { - let blocks = make_blocks(vec![ - ("tool_use", serde_json::json!({"name": "fn", "input": {}})), - ]); + 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 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}"); } @@ -6681,18 +6719,25 @@ mod tests { #[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": {}})), + ( + "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 'same'"), "got: {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": {}})), - ]); + let blocks = make_blocks(vec![( + "tool_use", + serde_json::json!({"id": "", "name": "fn", "input": {}}), + )]); assert!(convert_anthropic_blocks(&blocks).is_err()); } @@ -6715,8 +6760,9 @@ mod tests { 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 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}"); } @@ -6731,10 +6777,7 @@ mod tests { serde_json::json!({"type": "future_block", "data": "keep me"}), ]; let err = convert_anthropic_blocks(&blocks).unwrap_err(); - assert!( - err.contains("unsupported content block type 'future_block'"), - "got: {err}" - ); + assert!(err.contains("unsupported content block type"), "got: {err}"); } #[test] @@ -6745,10 +6788,7 @@ mod tests { 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 'server_tool_use'"), - "got: {err}" - ); + assert!(err.contains("unsupported content block type"), "got: {err}"); } #[test] @@ -6941,7 +6981,10 @@ mod tests { 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"); + assert_eq!( + echoed, payload, + "pure OpenAI requests must be byte-equivalent" + ); } #[tokio::test] @@ -7072,23 +7115,23 @@ mod tests { 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"); + 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); - }, - ) + 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; } @@ -7132,10 +7175,7 @@ mod tests { // 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!([]), - ] { + for existing in [serde_json::Value::Null, serde_json::json!([])] { let payload = serde_json::json!({ "model": "m", "messages": [ @@ -7155,5 +7195,4 @@ mod tests { ); } } - } From cc138ce84dcf662bf0339b43c96737986167780b Mon Sep 17 00:00:00 2001 From: guojn1 Date: Tue, 29 Sep 2026 17:59:02 +0800 Subject: [PATCH 5/8] fix(openai): align Anthropic block tests and clarify validation --- lib/llm/src/http/service/openai.rs | 42 +++++++++++++++++++++++++++--- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/lib/llm/src/http/service/openai.rs b/lib/llm/src/http/service/openai.rs index e723d9501b85..a93dc1dcfbec 100644 --- a/lib/llm/src/http/service/openai.rs +++ b/lib/llm/src/http/service/openai.rs @@ -542,7 +542,8 @@ fn has_anthropic_content_blocks(content: &serde_json::Value) -> bool { /// 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 losslessly. +/// 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]`, @@ -6935,9 +6936,9 @@ mod tests { {"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"}, - {"type": "text", "text": "done"} + {"type": "thinking", "thinking": "thinking B"} ]}, {"role": "tool", "tool_call_id": "t1", "content": "sunny"} ] @@ -6946,7 +6947,7 @@ mod tests { 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!("done")); + 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"], @@ -6967,6 +6968,39 @@ mod tests { assert_eq!(echoed["messages"][2], payload["messages"][2]); } + #[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; From bef1db80a6078b716d1eee59eb1bc39f22ec1ad3 Mon Sep 17 00:00:00 2001 From: guojn1 Date: Wed, 30 Sep 2026 12:09:30 +0800 Subject: [PATCH 6/8] fix(openai): convert Anthropic tool_result blocks --- lib/llm/src/http/service/openai.rs | 176 +++++++++++++++++++++++++++-- 1 file changed, 169 insertions(+), 7 deletions(-) diff --git a/lib/llm/src/http/service/openai.rs b/lib/llm/src/http/service/openai.rs index a93dc1dcfbec..0261aa142191 100644 --- a/lib/llm/src/http/service/openai.rs +++ b/lib/llm/src/http/service/openai.rs @@ -492,14 +492,12 @@ pub async fn smart_json_error_middleware(request: Request, next: Next) -> // // When a gateway translates Anthropic `/v1/messages` requests to OpenAI // `/v1/chat/completions` format it may leave Anthropic-specific content-block -// types inside assistant `content` arrays. The OpenAI deserializer only accepts -// `text` and `refusal` part types, so `thinking`, `tool_use`, and -// `redacted_thinking` blocks cause a 400 "data did not match any variant of -// untagged enum ChatCompletionRequestAssistantMessageContent" error. +// 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 every assistant message whose `content` is an array -// containing Anthropic block types, it converts: +// deserializes it. For assistant messages it converts: // // {type:"thinking", thinking:"…"} → reasoning_content (segments form) // {type:"redacted_thinking", …} → dropped (lossy: encrypted reasoning @@ -508,6 +506,10 @@ pub async fn smart_json_error_middleware(request: Request, next: Next) -> // {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 @@ -847,6 +849,141 @@ fn anthropic_compat_reject(byte_len: usize, message: String) -> Response { .into_response() } +/// Convert one Anthropic tool_result block to an OpenAI tool message. Tool +/// result arrays support text and base64 image blocks; unsupported blocks are +/// rejected rather than silently discarded. +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") => { + let source = part.get("source").ok_or_else(|| { + format!("tool_result image block {part_idx} is missing 'source'") + })?; + let source_type = source.get("type").and_then(serde_json::Value::as_str); + let media_type = + source.get("media_type").and_then(serde_json::Value::as_str); + let data = source.get("data").and_then(serde_json::Value::as_str); + if source_type != Some("base64") { + return Err(format!( + "tool_result image block {part_idx} uses an unsupported source type" + )); + } + let media_type = + media_type + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + format!( + "tool_result image block {part_idx} is missing 'media_type'" + ) + })?; + let data = data.ok_or_else(|| { + format!("tool_result image block {part_idx} is missing 'data'") + })?; + converted.push(serde_json::json!({ + "type": "image_url", + "image_url": {"url": format!("data:{media_type};base64,{data}")} + })); + } + _ => { + 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 { + // Anthropic text blocks already match OpenAI's text-part shape. + // Preserve other ordinary blocks as supplied so mixed content + // keeps its order and the downstream parser can validate it. + user_parts.push(block.clone()); + } + } + 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 { @@ -887,7 +1024,9 @@ pub async fn anthropic_content_compat_middleware(request: Request, next: N } }; - // Walk messages, convert assistant messages with Anthropic content blocks. + // 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() { @@ -1026,6 +1165,29 @@ pub async fn anthropic_content_compat_middleware(request: Request, next: N 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 From a96f3070c6f1ff992f1ac0adec4b2dd440b33a4b Mon Sep 17 00:00:00 2001 From: guojn1 Date: Wed, 30 Sep 2026 14:30:15 +0800 Subject: [PATCH 7/8] fix(openai): scope Anthropic tool result conversion --- lib/llm/src/http/service/openai.rs | 113 ++++++++++++++++++++--------- 1 file changed, 79 insertions(+), 34 deletions(-) diff --git a/lib/llm/src/http/service/openai.rs b/lib/llm/src/http/service/openai.rs index 0261aa142191..a7b3544b14d6 100644 --- a/lib/llm/src/http/service/openai.rs +++ b/lib/llm/src/http/service/openai.rs @@ -849,9 +849,9 @@ fn anthropic_compat_reject(byte_len: usize, message: String) -> Response { .into_response() } -/// Convert one Anthropic tool_result block to an OpenAI tool message. Tool -/// result arrays support text and base64 image blocks; unsupported blocks are -/// rejected rather than silently discarded. +/// 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") @@ -891,33 +891,9 @@ fn convert_anthropic_tool_result(block: &serde_json::Value) -> Result { - let source = part.get("source").ok_or_else(|| { - format!("tool_result image block {part_idx} is missing 'source'") - })?; - let source_type = source.get("type").and_then(serde_json::Value::as_str); - let media_type = - source.get("media_type").and_then(serde_json::Value::as_str); - let data = source.get("data").and_then(serde_json::Value::as_str); - if source_type != Some("base64") { - return Err(format!( - "tool_result image block {part_idx} uses an unsupported source type" - )); - } - let media_type = - media_type - .filter(|value| !value.is_empty()) - .ok_or_else(|| { - format!( - "tool_result image block {part_idx} is missing 'media_type'" - ) - })?; - let data = data.ok_or_else(|| { - format!("tool_result image block {part_idx} is missing 'data'") - })?; - converted.push(serde_json::json!({ - "type": "image_url", - "image_url": {"url": format!("data:{media_type};base64,{data}")} - })); + return Err(format!( + "image blocks in tool_result content are not supported (block index {part_idx})" + )); } _ => { return Err(format!( @@ -969,10 +945,13 @@ fn convert_anthropic_user_tool_results( .map_err(|error| format!("content block {block_idx}: {error}"))?, ); } else { - // Anthropic text blocks already match OpenAI's text-part shape. - // Preserve other ordinary blocks as supplied so mixed content - // keeps its order and the downstream parser can validate it. - user_parts.push(block.clone()); + // 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() { @@ -7130,6 +7109,72 @@ mod tests { 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; From 6d506083600a2d0a3c4bdc06bc61b04052ddd352 Mon Sep 17 00:00:00 2001 From: guojn1 Date: Wed, 30 Sep 2026 15:05:28 +0800 Subject: [PATCH 8/8] test(openai): cover Anthropic tool result conversion --- lib/llm/src/http/service/openai.rs | 100 +++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/lib/llm/src/http/service/openai.rs b/lib/llm/src/http/service/openai.rs index a7b3544b14d6..34aa1a89c17e 100644 --- a/lib/llm/src/http/service/openai.rs +++ b/lib/llm/src/http/service/openai.rs @@ -6661,6 +6661,106 @@ mod tests { 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