use super::*;
#[test]
fn detects_tools_unsupported_400_phrasings() {
assert!(is_tools_unsupported_error(&anyhow::anyhow!(
"Ollama 400 Bad Request: registry.ollama.ai/library/deepseek-r1:70b does not support tools"
)));
assert!(is_tools_unsupported_error(&anyhow::anyhow!(
"this model does not support tools at this time"
)));
assert!(!is_tools_unsupported_error(&anyhow::anyhow!(
"Ollama 400 Bad Request: context window exceeded"
)));
}
#[test]
fn detects_ollama_tool_xml_parser_errors() {
assert!(is_ollama_tool_xml_error(&anyhow::anyhow!(
"{}",
r#"Ollama 500 Internal Server Error: {"error":"XML syntax error on line 7: element \u003cparameter\u003e closed by \u003c/function\u003e"}"#
)));
assert!(is_ollama_tool_xml_error(&anyhow::anyhow!(
"{}",
r#"Ollama 500 Internal Server Error: {"error":"XML syntax error on line 2: element <parameter> closed by </function>"}"#
)));
assert!(is_ollama_tool_xml_error(&anyhow::anyhow!(
"{}",
r#"Ollama 500 Internal Server Error: {"error":"XML syntax error on line 3: unexpected end element \u003c/parameter\u003e"}"#
)));
assert!(!is_ollama_tool_xml_error(&anyhow::anyhow!(
"Ollama 500 Internal Server Error: model runner crashed"
)));
assert!(!is_ollama_tool_xml_error(&anyhow::anyhow!(
"OpenAI 400 Bad Request: XML syntax error in user supplied file"
)));
}
struct NoToolsResponder {
rejections: Arc<AtomicUsize>,
served_without_tools: Arc<AtomicBool>,
}
impl Respond for NoToolsResponder {
fn respond(&self, req: &Request) -> ResponseTemplate {
let has_tools = body_json(req).get("tools").is_some();
if has_tools {
self.rejections.fetch_add(1, Ordering::SeqCst);
return ResponseTemplate::new(400).set_body_string(
"registry.ollama.ai/library/deepseek-r1:70b does not support tools",
);
}
self.served_without_tools.store(true, Ordering::SeqCst);
ResponseTemplate::new(200).set_body_json(serde_json::json!({
"message": {"content": "hello there"},
"prompt_eval_count": 4, "eval_count": 2,
}))
}
}
#[tokio::test]
async fn no_tools_model_recovers_by_dropping_tools() {
let server = MockServer::start().await;
let rejections = Arc::new(AtomicUsize::new(0));
let served_without_tools = Arc::new(AtomicBool::new(false));
Mock::given(method("POST"))
.and(path("/api/chat"))
.respond_with(NoToolsResponder {
rejections: rejections.clone(),
served_without_tools: served_without_tools.clone(),
})
.mount(&server)
.await;
let messages = msgs();
let caveats = Caveats::top();
let (reply, streamed, _usage, _) =
chat_complete(ctx(&server.uri(), &messages, &caveats), &mut NoMcp)
.await
.expect("a no-tools model still answers a bare prompt");
assert_eq!(
crate::agentic::model_explanation(&reply),
"hello there",
"the tools-absent retry answered"
);
assert!(!streamed, "the host renders the accepted answer (#2372)");
assert!(
served_without_tools.load(Ordering::SeqCst),
"a request without the tools field was eventually served"
);
assert_eq!(
rejections.load(Ordering::SeqCst),
1,
"exactly one tools-bearing request 400s — the drop is self-limiting"
);
}
struct MalformedToolXmlResponder {
rejections: Arc<AtomicUsize>,
served_with_tools_after_error: Arc<AtomicBool>,
served_without_tools: Arc<AtomicBool>,
}
impl Respond for MalformedToolXmlResponder {
fn respond(&self, req: &Request) -> ResponseTemplate {
if body_json(req).get("tools").is_some() {
if self.rejections.fetch_add(1, Ordering::SeqCst) == 0 {
return ResponseTemplate::new(500).set_body_json(serde_json::json!({
"error": "XML syntax error on line 7: element <parameter> closed by </function>"
}));
}
self.served_with_tools_after_error
.store(true, Ordering::SeqCst);
return ResponseTemplate::new(200).set_body_json(serde_json::json!({
"message": {"content": "recovered with tools still available"},
"prompt_eval_count": 4,
"eval_count": 3,
}));
}
self.served_without_tools.store(true, Ordering::SeqCst);
if is_stream(req) {
ndjson(&[serde_json::json!({
"message": {"content": "unexpected no-tools stream"},
"done": true,
"prompt_eval_count": 4, "eval_count": 3
})])
} else {
ResponseTemplate::new(200).set_body_json(serde_json::json!({
"message": {"content": "unexpected no-tools probe"},
"prompt_eval_count": 4, "eval_count": 3,
}))
}
}
}
#[test]
fn malformed_tool_xml_responder_flags_unexpected_no_tools_request() {
let rejections = Arc::new(AtomicUsize::new(0));
let served_with_tools_after_error = Arc::new(AtomicBool::new(false));
let served_without_tools = Arc::new(AtomicBool::new(false));
let responder = MalformedToolXmlResponder {
rejections: rejections.clone(),
served_with_tools_after_error: served_with_tools_after_error.clone(),
served_without_tools: served_without_tools.clone(),
};
let req = Request {
url: "http://localhost/api/chat".parse().unwrap(),
method: "POST".parse().unwrap(),
headers: Default::default(),
body: serde_json::json!({ "stream": false })
.to_string()
.into_bytes(),
};
let _response = responder.respond(&req);
assert!(
served_without_tools.load(Ordering::SeqCst),
"the defensive no-tools branch should be observable"
);
assert_eq!(rejections.load(Ordering::SeqCst), 0);
assert!(!served_with_tools_after_error.load(Ordering::SeqCst));
}
#[tokio::test]
async fn ollama_tool_xml_error_recovers_with_tools_still_available() {
let server = MockServer::start().await;
let rejections = Arc::new(AtomicUsize::new(0));
let served_with_tools_after_error = Arc::new(AtomicBool::new(false));
let served_without_tools = Arc::new(AtomicBool::new(false));
Mock::given(method("POST"))
.and(path("/api/chat"))
.respond_with(MalformedToolXmlResponder {
rejections: rejections.clone(),
served_with_tools_after_error: served_with_tools_after_error.clone(),
served_without_tools: served_without_tools.clone(),
})
.mount(&server)
.await;
let messages = msgs();
let caveats = Caveats::top();
let (reply, streamed, _usage, _) =
chat_complete(ctx(&server.uri(), &messages, &caveats), &mut NoMcp)
.await
.expect("malformed XML tool-call parser errors should retry with tools");
assert_eq!(
crate::agentic::model_explanation(&reply),
"recovered with tools still available"
);
assert!(!streamed, "the host renders the accepted answer (#2372)");
assert!(
served_with_tools_after_error.load(Ordering::SeqCst),
"a tools-bearing request was served after the XML parser failure"
);
assert!(
!served_without_tools.load(Ordering::SeqCst),
"malformed XML must not disable tools for the turn"
);
assert_eq!(
rejections.load(Ordering::SeqCst),
2,
"the XML error probe and the retry probe both keep tools advertised"
);
}