use super::{ChatCompletionRequest, OpenAiTool, ResponseToolCall};
use crate::grammar::ToolChoice;
pub(crate) const TOOL_CALL_MARKERS: [&str; 2] = ["<tool_call>", "<function="];
pub(crate) const MAX_HELD_BYTES: usize = 10;
#[derive(Debug, Clone)]
pub(crate) struct StreamTools {
tools: Vec<OpenAiTool>,
tool_choice: Option<ToolChoice>,
}
impl StreamTools {
pub(crate) fn from_request(request: &ChatCompletionRequest) -> Option<Self> {
let tools = request.tools.as_ref()?;
let tool_choice = request
.tool_choice
.as_ref()
.map(super::OpenAiToolChoice::to_grammar);
if matches!(tool_choice, Some(ToolChoice::None)) {
return None;
}
Some(Self {
tools: tools.clone(),
tool_choice,
})
}
pub(crate) fn detector(self) -> ToolCallDetector {
ToolCallDetector {
tools: self,
held: String::new(),
captured: None,
}
}
}
#[derive(Debug, Default)]
pub(crate) struct DetectorEnd {
pub(crate) content: Option<String>,
pub(crate) calls: Vec<ResponseToolCall>,
}
#[derive(Debug)]
pub(crate) struct ToolCallDetector {
tools: StreamTools,
held: String,
captured: Option<String>,
}
impl ToolCallDetector {
pub(crate) fn push(&mut self, text: &str) -> Option<String> {
if let Some(captured) = self.captured.as_mut() {
captured.push_str(text);
return None;
}
let mut buf = std::mem::take(&mut self.held);
buf.push_str(text);
if let Some(at) = first_marker(&buf) {
self.captured = Some(buf[at..].to_string());
buf.truncate(at);
} else {
let keep = marker_prefix_len(&buf);
self.held = buf.split_off(buf.len() - keep);
}
(!buf.is_empty()).then_some(buf)
}
#[cfg(test)]
pub(crate) fn held_len(&self) -> usize {
self.held.len()
}
pub(crate) fn finish(self) -> DetectorEnd {
let held = (!self.held.is_empty()).then_some(self.held);
let Some(captured) = self.captured else {
return DetectorEnd {
content: held,
calls: Vec::new(),
};
};
let (message, _) = super::openai_handlers::build_tool_calling_message(
captured.clone(),
String::new(),
&self.tools.tools,
self.tools.tool_choice.as_ref(),
);
match message.tool_calls {
Some(calls) if !calls.is_empty() => DetectorEnd {
content: None,
calls,
},
_ => DetectorEnd {
content: Some(captured),
calls: Vec::new(),
},
}
}
}
fn first_marker(text: &str) -> Option<usize> {
TOOL_CALL_MARKERS.iter().filter_map(|m| text.find(m)).min()
}
fn marker_prefix_len(text: &str) -> usize {
(1..=MAX_HELD_BYTES.min(text.len()))
.rev()
.find(|&k| {
let start = text.len() - k;
text.is_char_boundary(start)
&& TOOL_CALL_MARKERS
.iter()
.any(|m| m.len() > k && m.starts_with(&text[start..]))
})
.unwrap_or(0)
}
pub(crate) fn detect(detector: &mut Option<ToolCallDetector>, text: String) -> Option<String> {
match detector {
Some(d) => d.push(&text),
None => Some(text),
}
}
pub(crate) fn finish(detector: Option<ToolCallDetector>) -> DetectorEnd {
detector.map(ToolCallDetector::finish).unwrap_or_default()
}
pub(crate) fn detect_all(
tools: Option<StreamTools>,
deltas: Vec<String>,
) -> (Vec<String>, Vec<ResponseToolCall>) {
let mut detector = tools.map(StreamTools::detector);
let mut out: Vec<String> = deltas
.into_iter()
.filter_map(|d| detect(&mut detector, d))
.collect();
let end = finish(detector);
out.extend(end.content);
(out, end.calls)
}
pub(crate) fn finish_reason(
calls: &[ResponseToolCall],
generated: super::FinishReason,
) -> super::FinishReason {
if calls.is_empty() {
generated
} else {
super::FinishReason::ToolCalls
}
}