use serde_json::{Value, json};
use crate::types::{ChatMessage, FunctionCall, ToolCall};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ReasoningMode {
#[default]
Default,
On,
Off,
}
impl ReasoningMode {
fn enable_thinking(self) -> Option<bool> {
match self {
ReasoningMode::Default => None,
ReasoningMode::On => Some(true),
ReasoningMode::Off => Some(false),
}
}
}
pub fn strip_think(content: &str) -> (String, Option<String>) {
const OPEN: &str = "<think>";
const CLOSE: &str = "</think>";
let lower = content.to_ascii_lowercase();
if !lower.contains(OPEN) {
return (content.to_string(), None);
}
let mut cleaned = String::with_capacity(content.len());
let mut think = String::new();
let mut cursor = 0usize;
while let Some(rel_open) = lower[cursor..].find(OPEN) {
let open = cursor + rel_open;
cleaned.push_str(&content[cursor..open]);
let inner_start = open + OPEN.len();
match lower[inner_start..].find(CLOSE) {
Some(rel_close) => {
let close = inner_start + rel_close;
think.push_str(content[inner_start..close].trim());
think.push('\n');
cursor = close + CLOSE.len();
}
None => {
think.push_str(content[inner_start..].trim());
cursor = content.len();
break;
}
}
}
cleaned.push_str(&content[cursor..]);
let think = think.trim();
let think = if think.is_empty() {
None
} else {
Some(think.to_string())
};
(cleaned, think)
}
pub fn extract_tool_calls(content: &str) -> (String, Vec<ToolCall>) {
const OPEN: &str = "<tool_call>";
const CLOSE: &str = "</tool_call>";
if !content.contains(OPEN) {
return (content.to_string(), Vec::new());
}
let mut remaining = String::with_capacity(content.len());
let mut calls = Vec::new();
let mut cursor = 0usize;
while let Some(rel_open) = content[cursor..].find(OPEN) {
let open = cursor + rel_open;
let inner_start = open + OPEN.len();
let Some(rel_close) = content[inner_start..].find(CLOSE) else {
break;
};
let close = inner_start + rel_close;
let inner = content[inner_start..close].trim();
match parse_hermes_call(inner, calls.len()) {
Some(call) => {
remaining.push_str(&content[cursor..open]);
cursor = close + CLOSE.len();
calls.push(call);
}
None => {
remaining.push_str(&content[cursor..close + CLOSE.len()]);
cursor = close + CLOSE.len();
}
}
}
remaining.push_str(&content[cursor..]);
(remaining, calls)
}
fn parse_hermes_call(inner: &str, index: usize) -> Option<ToolCall> {
let value: Value = serde_json::from_str(inner).ok()?;
let name = value.get("name")?.as_str()?.to_string();
let arguments = match value.get("arguments") {
Some(Value::String(s)) => s.clone(),
Some(other) => other.to_string(),
None => "{}".to_string(),
};
Some(ToolCall {
id: format!("call_{index}"),
call_type: "function".to_string(),
function: FunctionCall { name, arguments },
})
}
pub fn normalize_message(message: &mut ChatMessage) -> bool {
let Some(raw) = message.content.take() else {
return false;
};
let (cleaned, _think) = strip_think(&raw);
let has_native_calls = message.tool_calls.as_ref().is_some_and(|c| !c.is_empty());
let (text, recovered) = if has_native_calls {
(cleaned, Vec::new())
} else {
extract_tool_calls(&cleaned)
};
let recovered_any = !recovered.is_empty();
if recovered_any {
message.tool_calls = Some(recovered);
}
let text = text.trim();
message.content = if text.is_empty() {
None
} else {
Some(text.to_string())
};
recovered_any
}
pub fn augment_request_body(body: &mut Value, num_ctx: Option<u32>, reasoning: ReasoningMode) {
let Some(obj) = body.as_object_mut() else {
return;
};
if let Some(ctx) = num_ctx {
let options = obj.entry("options").or_insert_with(|| json!({}));
if let Some(options) = options.as_object_mut() {
options.insert("num_ctx".to_string(), json!(ctx));
}
}
if let Some(enable) = reasoning.enable_thinking() {
let kwargs = obj
.entry("chat_template_kwargs")
.or_insert_with(|| json!({}));
if let Some(kwargs) = kwargs.as_object_mut() {
kwargs.insert("enable_thinking".to_string(), json!(enable));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strip_think_removes_block_and_routes_reasoning() {
let (cleaned, think) = strip_think("<think>I should greet them.</think>Hello there!");
assert_eq!(cleaned, "Hello there!");
assert_eq!(think.as_deref(), Some("I should greet them."));
}
#[test]
fn strip_think_no_block_is_identity() {
let (cleaned, think) = strip_think("Just a plain answer.");
assert_eq!(cleaned, "Just a plain answer.");
assert!(think.is_none());
}
#[test]
fn strip_think_multiple_blocks() {
let (cleaned, think) = strip_think("<think>a</think>X<think>b</think>Y");
assert_eq!(cleaned, "XY");
assert_eq!(think.as_deref(), Some("a\nb"));
}
#[test]
fn strip_think_unterminated_consumes_to_end() {
let (cleaned, think) = strip_think("done.<think>truncated reasoning...");
assert_eq!(cleaned, "done.");
assert_eq!(think.as_deref(), Some("truncated reasoning..."));
}
#[test]
fn strip_think_case_insensitive() {
let (cleaned, _) = strip_think("<THINK>x</THINK>y");
assert_eq!(cleaned, "y");
}
#[test]
fn extract_single_tool_call_object_arguments() {
let content =
r#"<tool_call>{"name": "get_weather", "arguments": {"city": "SF"}}</tool_call>"#;
let (remaining, calls) = extract_tool_calls(content);
assert_eq!(remaining.trim(), "");
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].id, "call_0");
assert_eq!(calls[0].call_type, "function");
assert_eq!(calls[0].function.name, "get_weather");
let parsed: Value = serde_json::from_str(&calls[0].function.arguments).unwrap();
assert_eq!(parsed["city"], "SF");
}
#[test]
fn extract_tool_call_string_arguments_pass_through() {
let content = r#"<tool_call>{"name": "echo", "arguments": "{\"x\":1}"}</tool_call>"#;
let (_, calls) = extract_tool_calls(content);
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].function.arguments, r#"{"x":1}"#);
}
#[test]
fn extract_multiple_tool_calls_keep_text() {
let content = concat!(
"Let me check both.\n",
r#"<tool_call>{"name": "a", "arguments": {}}</tool_call>"#,
"\n",
r#"<tool_call>{"name": "b", "arguments": {}}</tool_call>"#,
);
let (remaining, calls) = extract_tool_calls(content);
assert_eq!(calls.len(), 2);
assert_eq!(calls[0].id, "call_0");
assert_eq!(calls[1].id, "call_1");
assert!(remaining.contains("Let me check both."));
assert!(!remaining.contains("<tool_call>"));
}
#[test]
fn extract_no_tool_call_is_identity() {
let (remaining, calls) = extract_tool_calls("No tools here.");
assert_eq!(remaining, "No tools here.");
assert!(calls.is_empty());
}
#[test]
fn extract_malformed_block_left_verbatim() {
let content = r#"<tool_call>not json</tool_call>"#;
let (remaining, calls) = extract_tool_calls(content);
assert!(calls.is_empty());
assert_eq!(remaining, content);
}
#[test]
fn extract_unterminated_block_left_verbatim() {
let content = r#"prefix <tool_call>{"name":"a"}"#;
let (remaining, calls) = extract_tool_calls(content);
assert!(calls.is_empty());
assert_eq!(remaining, content);
}
#[test]
fn think_mentioned_tool_call_is_not_extracted() {
let content = concat!(
r#"<think>I will call <tool_call>{"name":"WRONG","arguments":{}}</tool_call></think>"#,
r#"<tool_call>{"name":"right","arguments":{"q":"x"}}</tool_call>"#,
);
let (cleaned, _) = strip_think(content);
let (_, calls) = extract_tool_calls(&cleaned);
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].function.name, "right");
}
#[test]
fn normalize_recovers_tool_calls_from_content() {
let mut msg = ChatMessage {
role: "assistant".into(),
content: Some(
r#"<think>plan</think><tool_call>{"name":"search","arguments":{"q":"rust"}}</tool_call>"#
.into(),
),
tool_call_id: None,
tool_calls: None,
};
let recovered = normalize_message(&mut msg);
assert!(recovered);
assert!(msg.content.is_none());
let calls = msg.tool_calls.unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].function.name, "search");
assert!(!calls[0].function.arguments.contains("plan"));
}
#[test]
fn normalize_keeps_native_tool_calls_and_strips_think() {
let native = ToolCall {
id: "call_native".into(),
call_type: "function".into(),
function: FunctionCall {
name: "already_parsed".into(),
arguments: "{}".into(),
},
};
let mut msg = ChatMessage {
role: "assistant".into(),
content: Some("<think>reasoning</think>".into()),
tool_call_id: None,
tool_calls: Some(vec![native.clone()]),
};
let recovered = normalize_message(&mut msg);
assert!(!recovered);
assert_eq!(msg.tool_calls.unwrap()[0].id, "call_native");
assert!(msg.content.is_none());
}
#[test]
fn normalize_plain_text_passes_through() {
let mut msg = ChatMessage::assistant("Hello!");
let recovered = normalize_message(&mut msg);
assert!(!recovered);
assert_eq!(msg.content.as_deref(), Some("Hello!"));
assert!(msg.tool_calls.is_none());
}
#[test]
fn augment_injects_num_ctx_and_reasoning() {
let mut body = json!({"model": "hermes", "messages": []});
augment_request_body(&mut body, Some(32768), ReasoningMode::Off);
assert_eq!(body["options"]["num_ctx"], 32768);
assert_eq!(body["chat_template_kwargs"]["enable_thinking"], false);
}
#[test]
fn augment_reasoning_on() {
let mut body = json!({"model": "hermes"});
augment_request_body(&mut body, None, ReasoningMode::On);
assert_eq!(body["chat_template_kwargs"]["enable_thinking"], true);
assert!(body.get("options").is_none());
}
#[test]
fn augment_default_injects_nothing() {
let mut body = json!({"model": "hermes"});
augment_request_body(&mut body, None, ReasoningMode::Default);
assert!(body.get("options").is_none());
assert!(body.get("chat_template_kwargs").is_none());
}
#[test]
fn augment_preserves_existing_options() {
let mut body = json!({"model": "hermes", "options": {"temperature": 0.5}});
augment_request_body(&mut body, Some(8192), ReasoningMode::Default);
assert_eq!(body["options"]["temperature"], 0.5);
assert_eq!(body["options"]["num_ctx"], 8192);
}
}