1use super::*;
2
3#[derive(Default)]
4pub(crate) struct StreamToolUseBlock {
5 pub(crate) id: String,
6 pub(crate) name: String,
7 pub(crate) input_json: String,
8 pub(crate) thought_signature: Option<String>,
9}
10
11pub(crate) fn usage_from_json(v: &serde_json::Value) -> Option<Usage> {
12 let input = v
13 .get("input_tokens")
14 .and_then(json_u64)
15 .or_else(|| v.get("prompt_tokens").and_then(json_u64))?;
16 let output = v
17 .get("output_tokens")
18 .and_then(json_u64)
19 .or_else(|| v.get("completion_tokens").and_then(json_u64))
20 .unwrap_or(0);
21 Some(Usage {
22 input_tokens: u32::try_from(input).unwrap_or(u32::MAX),
23 output_tokens: u32::try_from(output).unwrap_or(u32::MAX),
24 })
25}
26
27pub(crate) fn json_u64(v: &serde_json::Value) -> Option<u64> {
28 match v {
29 serde_json::Value::Number(n) => n.as_u64(),
30 serde_json::Value::String(s) => s.parse::<u64>().ok(),
31 _ => None,
32 }
33}
34
35pub(crate) fn merge_usage_max(slot: &mut Option<Usage>, incoming: Usage) {
38 match slot {
39 Some(current) => {
40 current.input_tokens = current.input_tokens.max(incoming.input_tokens);
41 current.output_tokens = current.output_tokens.max(incoming.output_tokens);
42 }
43 None => {
44 *slot = Some(incoming);
45 }
46 }
47}
48
49pub(crate) fn normalize_stop_reason(reason: Option<String>) -> Option<String> {
50 match reason.as_deref() {
51 Some("tool_use") | Some("tool_calls") => Some("tool_use".into()),
52 Some("max_tokens") | Some("length") => Some("max_tokens".into()),
53 Some("stop") | Some("end_turn") | None => Some("end_turn".into()),
54 Some(other) => Some(other.to_string()),
55 }
56}
57
58pub(crate) fn parse_tool_input(input_json: &str) -> serde_json::Value {
59 let trimmed = input_json.trim();
60 if trimmed.is_empty() {
61 return json!({});
62 }
63 serde_json::from_str(trimmed).unwrap_or_else(|_| json!({}))
64}
65
66pub(crate) fn normalize_tool_input_for_request(input: &serde_json::Value) -> serde_json::Value {
67 match input {
68 serde_json::Value::Object(_) => input.clone(),
69 serde_json::Value::String(text) => {
70 let trimmed = text.trim();
71 if trimmed.is_empty() {
72 return json!({});
73 }
74 match serde_json::from_str::<serde_json::Value>(trimmed) {
75 Ok(serde_json::Value::Object(map)) => serde_json::Value::Object(map),
76 _ => json!({}),
77 }
78 }
79 _ => json!({}),
80 }
81}
82
83pub(crate) fn minimax_tool_wrapper_regex() -> &'static Regex {
84 static RE: OnceLock<Regex> = OnceLock::new();
85 RE.get_or_init(|| {
86 Regex::new(r"</?(?:minimax:tool_call|invoke|parameter)>")
87 .expect("MiniMax tool wrapper regex must compile")
88 })
89}
90
91pub(crate) fn strip_minimax_tool_wrappers(text: &str) -> String {
92 minimax_tool_wrapper_regex()
93 .replace_all(text, " ")
94 .into_owned()
95}
96
97pub(crate) fn parse_raw_tool_use_block(
98 input: &str,
99 call_number: usize,
100) -> Option<(StreamToolUseBlock, &str)> {
101 let rest = input.trim_start();
102 let prefix = "[tool_use:";
103 if !rest.starts_with(prefix) {
104 return None;
105 }
106
107 let mut cursor = prefix.len();
108 let after_prefix = &rest[cursor..];
109 let name_and_args = after_prefix.trim_start();
110 cursor += after_prefix.len().saturating_sub(name_and_args.len());
111
112 let open_paren_rel = name_and_args.find('(')?;
113 let name = name_and_args[..open_paren_rel].trim();
114 if name.is_empty() {
115 return None;
116 }
117 cursor += open_paren_rel + 1;
118
119 let mut depth = 1usize;
120 let mut in_string = false;
121 let mut escaping = false;
122 let mut close_paren_at: Option<usize> = None;
123
124 for (offset, ch) in rest[cursor..].char_indices() {
125 if in_string {
126 if escaping {
127 escaping = false;
128 continue;
129 }
130 match ch {
131 '\\' => escaping = true,
132 '"' => in_string = false,
133 _ => {}
134 }
135 continue;
136 }
137
138 match ch {
139 '"' => in_string = true,
140 '(' => depth += 1,
141 ')' => {
142 depth = depth.saturating_sub(1);
143 if depth == 0 {
144 close_paren_at = Some(cursor + offset);
145 break;
146 }
147 }
148 _ => {}
149 }
150 }
151
152 let close_paren_at = close_paren_at?;
153 let args = rest[cursor..close_paren_at].trim();
154 let mut tail = &rest[close_paren_at + 1..];
155 tail = tail.trim_start();
156 if !tail.starts_with(']') {
157 return None;
158 }
159
160 Some((
161 StreamToolUseBlock {
162 id: format!("raw_tool_call_{call_number}"),
163 name: name.to_string(),
164 input_json: if args.is_empty() {
165 "{}".to_string()
166 } else {
167 args.to_string()
168 },
169 thought_signature: None,
170 },
171 &tail[1..],
172 ))
173}
174
175pub(crate) fn extract_raw_tool_use_blocks(text: &str) -> Option<Vec<StreamToolUseBlock>> {
176 let normalized = strip_minimax_tool_wrappers(text);
177 if !normalized.contains("[tool_use:") {
178 return None;
179 }
180
181 let mut calls = Vec::new();
182 let mut rest = normalized.as_str();
183 loop {
184 let trimmed = rest.trim();
185 if trimmed.is_empty() {
186 break;
187 }
188 let (call, tail) = parse_raw_tool_use_block(trimmed, calls.len() + 1)?;
189 calls.push(call);
190 rest = tail;
191 }
192
193 if calls.is_empty() {
194 None
195 } else {
196 Some(calls)
197 }
198}
199
200pub(crate) fn has_tool_use_block(content: &[ResponseContentBlock]) -> bool {
201 content
202 .iter()
203 .any(|b| matches!(b, ResponseContentBlock::ToolUse { .. }))
204}
205
206pub(crate) fn combine_visible_and_reasoning_text(visible: &str, reasoning: &str) -> String {
207 let visible = visible.trim();
208 let reasoning = reasoning.trim();
209 if reasoning.is_empty() {
210 return visible.to_string();
211 }
212 if visible.is_empty() {
213 return format!("<thought>\n{}\n</thought>", reasoning);
214 }
215 format!("<thought>\n{}\n</thought>\n\n{}", reasoning, visible)
216}
217
218pub(crate) fn combine_response_text_for_display(
219 visible: &str,
220 reasoning: &str,
221 show_thinking: bool,
222) -> String {
223 if show_thinking {
224 combine_visible_and_reasoning_text(visible, reasoning)
225 } else {
226 visible.trim().to_string()
227 }
228}
229
230pub(crate) fn build_stream_response(
231 ordered_indexes: Vec<usize>,
232 text_blocks: std::collections::HashMap<usize, String>,
233 tool_blocks: std::collections::HashMap<usize, StreamToolUseBlock>,
234 stop_reason: Option<String>,
235 usage: Option<Usage>,
236) -> MessagesResponse {
237 let mut content = Vec::new();
238 for index in ordered_indexes {
239 if let Some(text) = text_blocks.get(&index) {
240 if !text.is_empty() {
241 content.push(ResponseContentBlock::Text { text: text.clone() });
242 }
243 }
244 if let Some(tool) = tool_blocks.get(&index) {
245 content.push(ResponseContentBlock::ToolUse {
246 id: sanitize_tool_id(&tool.id),
247 name: tool.name.clone(),
248 input: parse_tool_input(&tool.input_json),
249 thought_signature: tool.thought_signature.clone(),
250 });
251 }
252 }
253
254 if content.is_empty() {
255 content.push(ResponseContentBlock::Text {
256 text: String::new(),
257 });
258 }
259
260 let mut normalized_stop_reason = normalize_stop_reason(stop_reason);
261 if !tool_blocks.is_empty() {
262 normalized_stop_reason = Some("tool_use".to_string());
263 } else if normalized_stop_reason.as_deref() == Some("tool_use") && !has_tool_use_block(&content)
264 {
265 warn!("Downgrading stop_reason=tool_use to end_turn because no tool_calls were parsed");
266 normalized_stop_reason = Some("end_turn".into());
267 }
268
269 MessagesResponse {
270 content,
271 stop_reason: normalized_stop_reason,
272 usage,
273 }
274}
275
276#[cfg(test)]
277mod tests {
278 use super::*;
279 #[allow(unused_imports)]
280 use crate::llm::test_prelude::*;
281
282 #[test]
283 fn test_normalize_stop_reason_stream_variants() {
284 assert_eq!(
285 normalize_stop_reason(Some("tool_calls".into())).as_deref(),
286 Some("tool_use")
287 );
288 assert_eq!(
289 normalize_stop_reason(Some("length".into())).as_deref(),
290 Some("max_tokens")
291 );
292 assert_eq!(
293 normalize_stop_reason(Some("stop".into())).as_deref(),
294 Some("end_turn")
295 );
296 }
297
298 #[test]
299 fn test_build_stream_response_tool_calls_without_blocks_downgrades_to_end_turn() {
300 let resp = build_stream_response(
301 vec![],
302 std::collections::HashMap::new(),
303 std::collections::HashMap::new(),
304 Some("tool_calls".into()),
305 None,
306 );
307 assert_eq!(resp.stop_reason.as_deref(), Some("end_turn"));
308 assert!(!resp
309 .content
310 .iter()
311 .any(|b| matches!(b, ResponseContentBlock::ToolUse { .. })));
312 }
313
314 #[test]
315 fn test_extract_raw_tool_use_blocks_from_minimax_wrappers() {
316 let text = "<minimax:tool_call>\n<invoke>\n<parameter>\n[tool_use: bash({\"command\":\"uptime\"})]\n</parameter>\n</invoke>\n</minimax:tool_call>";
317 let calls = extract_raw_tool_use_blocks(text).expect("should parse raw tool calls");
318 assert_eq!(calls.len(), 1);
319 assert_eq!(calls[0].name, "bash");
320 assert_eq!(calls[0].input_json, "{\"command\":\"uptime\"}");
321 }
322
323 #[test]
324 fn test_usage_from_json_supports_openai_prompt_completion_tokens() {
325 let v = json!({
326 "prompt_tokens": 12,
327 "completion_tokens": 34
328 });
329 let usage = usage_from_json(&v).expect("usage should parse");
330 assert_eq!(usage.input_tokens, 12);
331 assert_eq!(usage.output_tokens, 34);
332 }
333
334 #[test]
335 fn test_usage_from_json_supports_numeric_strings() {
336 let v = json!({
337 "input_tokens": "56",
338 "output_tokens": "78"
339 });
340 let usage = usage_from_json(&v).expect("usage should parse");
341 assert_eq!(usage.input_tokens, 56);
342 assert_eq!(usage.output_tokens, 78);
343 }
344
345 #[test]
346 fn test_build_stream_response_tool_json_parsing() {
347 let mut tool_blocks = std::collections::HashMap::new();
348 tool_blocks.insert(
349 0,
350 StreamToolUseBlock {
351 id: "call_1".into(),
352 name: "bash".into(),
353 input_json: r#"{"command":"ls","cwd":"/tmp"}"#.into(),
354 thought_signature: None,
355 },
356 );
357 let resp = build_stream_response(
358 vec![0],
359 std::collections::HashMap::new(),
360 tool_blocks,
361 Some("tool_use".into()),
362 None,
363 );
364 assert_eq!(resp.stop_reason.as_deref(), Some("tool_use"));
365 match &resp.content[0] {
366 ResponseContentBlock::ToolUse {
367 id,
368 name,
369 input,
370 thought_signature,
371 } => {
372 assert_eq!(id, "call_1");
373 assert_eq!(name, "bash");
374 assert_eq!(input["command"], "ls");
375 assert_eq!(input["cwd"], "/tmp");
376 assert!(thought_signature.is_none());
377 }
378 _ => panic!("Expected ToolUse"),
379 }
380 }
381
382 }