#![allow(clippy::too_many_arguments)]
use crate::AppState;
use crate::ir;
use crate::tool_parser;
use super::chat_blocking::{extract_hoisted_tool_calls, merge_hoisted_tool_calls};
pub(super) fn log_tool_raw_on() -> bool {
std::env::var("METRALE_LOG_TOOL_RAW").as_deref() == Ok("1")
}
pub(super) fn log_raw_generation(state: &AppState, tokens: &[u32], what: &str) {
if !log_tool_raw_on() {
return;
}
match state.tokenizer.decode_with_special(tokens) {
Ok(text) => tracing::info!(
target: "metrale::tool_debug",
"raw generation ({what}, {} tokens, special tokens kept): {text:?}",
tokens.len()
),
Err(e) => tracing::warn!(
target: "metrale::tool_debug",
"raw generation ({what}): decode failed: {e:#}"
),
}
}
pub(super) fn vllm_split_content(
content: Option<String>,
forced_tool_choice: bool,
) -> Option<String> {
let content = content.filter(|c| !c.is_empty());
if forced_tool_choice {
Some(content.unwrap_or_default())
} else {
content
}
}
pub(super) fn build_choice_message(
state: &AppState,
req: &crate::ir::ChatRequest,
response: &super::inference_types::InferenceResponse,
reasoning_content_i: Option<String>,
output_text_i: String,
tools_active: bool,
forced_tool_choice: bool,
cwd_hint: Option<&str>,
choice_idx: usize,
) -> ir::Choice {
log_raw_generation(
state,
&response.output_tokens,
&format!("blocking choice {choice_idx}"),
);
let mut reasoning_content = reasoning_content_i;
let mut msg_content: Option<String> = Some(output_text_i.clone());
let mut msg_tool_calls: Option<Vec<tool_parser::ToolCall>> = None;
let mut msg_refusal: Option<String> = None;
let mut finish_reason_i = response.finish_reason.clone();
let fail_closed = state
.tool_call_parser
.as_ref()
.is_some_and(|p| matches!(p.call_policy(), tool_parser::CallPolicy::FailClosed { .. }));
if tools_active && fail_closed {
let (content, verdicts) = tool_parser::parse_glm47_answer(&output_text_i, &req.tools);
msg_content = content;
if !verdicts.is_empty() {
let calls: Vec<tool_parser::ToolCall> = verdicts
.into_iter()
.map(tool_parser::Verdict::into_tool_call)
.collect();
crate::metrics::TOOL_CALLS_TOTAL.inc_by(calls.len() as u64);
msg_tool_calls = Some(calls);
if finish_reason_i != ir::FINISH_REASON_TIMEOUT {
finish_reason_i = "tool_calls".to_string();
}
}
} else if tools_active {
if log_tool_raw_on() {
tracing::info!(
target: "metrale::tool_debug",
"raw pre-parse output (tools_active, choice {choice_idx}): {output_text_i:?}"
);
}
let parser_name = state.tool_call_parser.as_ref().map(|parser| parser.name());
let (hoisted_reasoning, hoisted_tool_calls) =
extract_hoisted_tool_calls(reasoning_content.as_deref(), parser_name);
if !hoisted_tool_calls.is_empty() {
tracing::info!(
"F7: hoisted {} tool-call(s) from inside <think> block (would have been silently dropped)",
hoisted_tool_calls.len()
);
reasoning_content = hoisted_reasoning;
}
let promote_bare_names = state
.tool_call_parser
.as_ref()
.is_some_and(|p| p.promotes_bare_call_names());
let (content, parsed_tool_calls) = if promote_bare_names {
tool_parser::parse_tool_calls_promoting_bare_names(&output_text_i)
} else {
tool_parser::parse_tool_calls(&output_text_i)
};
let mut tool_calls_i = merge_hoisted_tool_calls(hoisted_tool_calls, parsed_tool_calls);
if !tool_calls_i.is_empty() {
let tools_ref = req.tools.clone();
tool_parser::backfill_required_params(&mut tool_calls_i, &tools_ref);
if state
.tool_call_parser
.as_ref()
.is_some_and(|p| p.wants_typed_arguments())
{
tool_parser::coerce_all(&mut tool_calls_i, &tools_ref);
}
if let Some(cwd) = cwd_hint {
tool_parser::normalize_paths(&mut tool_calls_i, cwd);
}
let validated = tool_parser::validate_tool_calls(tool_calls_i, &tools_ref);
if !validated.errors.is_empty() {
for err in &validated.errors {
tracing::warn!("Tool call validation error: {err}");
}
}
let content = content.map(|mut c| {
for tag in &["</parameter>", "</function>", "</tool_call>", "<tool_call>"] {
c = c.replace(tag, "");
}
while let Some(start) = c.find("<function=") {
let end = c[start..]
.find('>')
.map(|p| start + p + 1)
.unwrap_or(c.len());
c = format!("{}{}", &c[..start], &c[end..]);
}
while let Some(start) = c.find("```") {
let after_open = start + 3;
let Some(rel_close) = c[after_open..].find("```") else {
break;
};
let close_end = after_open + rel_close + 3;
c = format!("{}{}", &c[..start], &c[close_end..]);
}
c.trim().to_string()
});
msg_content = content;
if !validated.valid.is_empty() {
for tc in &validated.valid {
let p: String = tc.function.arguments.chars().take(120).collect();
let s = ["", "…"][usize::from(tc.function.arguments.len() > p.len())];
tracing::info!("Tool call: {}({p}{s})", tc.function.name);
crate::metrics::TOOL_CALLS_TOTAL.inc();
}
msg_tool_calls = Some(validated.valid);
if finish_reason_i != ir::FINISH_REASON_TIMEOUT {
finish_reason_i = "tool_calls".to_string();
}
}
}
}
if msg_tool_calls.is_none() && !(tools_active && fail_closed) {
msg_content = msg_content.map(|c| super::strip::strip_orphan_tool_markup(&c));
}
if msg_tool_calls.is_none()
&& let Some(content_text) = msg_content.as_deref()
&& let Some(refusal_sentence) = crate::refusal::detect(content_text)
{
msg_refusal = Some(refusal_sentence);
msg_content = None;
}
if msg_refusal.is_none()
&& state
.reasoning_parser
.as_deref()
.is_some_and(|p| p.tool_call_closes_reasoning())
{
msg_content = vllm_split_content(msg_content, forced_tool_choice);
}
let tool_calls: Vec<ir::message::ToolCall> = msg_tool_calls
.unwrap_or_default()
.into_iter()
.map(|tc| ir::message::ToolCall {
id: tc.id,
name: tc.function.name,
arguments: serde_json::from_str(&tc.function.arguments)
.unwrap_or_else(|_| serde_json::Value::Object(Default::default())),
})
.collect();
ir::Choice {
index: choice_idx,
content: msg_content,
reasoning: reasoning_content,
tool_calls,
refusal: msg_refusal,
finish_reason: ir::FinishReason::from(finish_reason_i.as_str()),
matched_stop: None,
logprobs: None,
}
}
pub(super) fn build_logprobs(
state: &AppState,
response: &super::inference_types::InferenceResponse,
) -> Option<ir::ChoiceLogprobs> {
if response.logprobs.is_empty() {
return None;
}
Some(ir::ChoiceLogprobs {
content: response
.logprobs
.iter()
.map(|lp| {
let token_str = state.tokenizer.decode(&[lp.token_id]).unwrap_or_default();
ir::TokenLogprob {
token: token_str,
logprob: lp.logprob,
top: lp
.top
.iter()
.map(|&(tid, lp_val)| {
(state.tokenizer.decode(&[tid]).unwrap_or_default(), lp_val)
})
.collect(),
}
})
.collect(),
})
}