Skip to main content

microclaw_engine/llm/
stream.rs

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
35// OpenAI-compatible streaming usage is cumulative (running total), not per-chunk delta.
36// We therefore keep the max seen values instead of summing to avoid double counting.
37pub(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    // -----------------------------------------------------------------------
383    // create_provider
384    // -----------------------------------------------------------------------
385}