met 1.0.0-beta-preview

Metrale Engine: pure Rust LLM inference server (HTTP API, scheduler, TUI, CLI).
// SPDX-License-Identifier: MIT OR Apache-2.0

//! 2026-09-26: Message preprocessing for the DeepSeek-V4 encoder: request-level tools go on
//! the first system (else developer) message, tool messages become `tool_result` blocks
//! sorted by call order, and assistant turns before the last user message lose their
//! reasoning.
//!
//! Owner: server (tokenizer).
//! Invariants: none beyond the types.

use anyhow::{Context, Result};
use serde_json::{Value, json};
use std::collections::HashMap;

fn role(message: &Value) -> Option<&str> {
    message.get("role").and_then(Value::as_str)
}

fn last_user_index(messages: &[Value]) -> Option<usize> {
    messages
        .iter()
        .rposition(|m| matches!(role(m), Some("user" | "developer")))
}

pub(super) fn attach_top_level_tools(
    messages: &mut Vec<Value>,
    tools: Option<&[Value]>,
) -> Result<()> {
    let Some(tools) = tools.filter(|v| !v.is_empty()) else {
        return Ok(());
    };
    if messages.iter().any(|m| {
        m.get("tools")
            .and_then(Value::as_array)
            .is_some_and(|v| !v.is_empty())
    }) {
        return Ok(());
    }

    let tools = Value::Array(tools.to_vec());
    let recipient = messages
        .iter()
        .position(|m| role(m) == Some("system"))
        .or_else(|| messages.iter().position(|m| role(m) == Some("developer")));
    if let Some(index) = recipient {
        let message = &mut messages[index];
        message
            .as_object_mut()
            .context("DeepSeek-V4 message must be an object")?
            .insert("tools".into(), tools);
    } else {
        messages.insert(0, json!({"role": "system", "content": "", "tools": tools}));
    }
    Ok(())
}

pub(super) fn merge_tool_messages(messages: &[Value]) -> Result<Vec<Value>> {
    let mut merged: Vec<Value> = Vec::with_capacity(messages.len());
    for message in messages {
        let message_role = role(message).unwrap_or_default();
        match message_role {
            "tool" => {
                let block = json!({
                    "type": "tool_result",
                    "tool_use_id": message.get("tool_call_id").cloned().unwrap_or(Value::String(String::new())),
                    "content": message.get("content").cloned().unwrap_or(Value::String(String::new())),
                });
                if let Some(blocks) = merged.last_mut().and_then(user_content_blocks_mut) {
                    blocks.push(block);
                } else {
                    merged.push(json!({"role": "user", "content_blocks": [block]}));
                }
            }
            "user" => {
                let content = message
                    .get("content")
                    .cloned()
                    .unwrap_or(Value::String(String::new()));
                let block = json!({"type": "text", "text": content});
                let can_merge = merged.last().is_some_and(|m| {
                    role(m) == Some("user")
                        && m.get("content_blocks").and_then(Value::as_array).is_some()
                        && m.get("task").is_none()
                });
                if can_merge {
                    merged
                        .last_mut()
                        .and_then(user_content_blocks_mut)
                        .expect("checked content_blocks")
                        .push(block);
                } else {
                    let mut new_message = message.clone();
                    new_message
                        .as_object_mut()
                        .context("DeepSeek-V4 user message must be an object")?
                        .insert("content_blocks".into(), Value::Array(vec![block]));
                    merged.push(new_message);
                }
            }
            _ => merged.push(message.clone()),
        }
    }
    Ok(merged)
}

fn user_content_blocks_mut(message: &mut Value) -> Option<&mut Vec<Value>> {
    (role(message) == Some("user"))
        .then(|| message.get_mut("content_blocks")?.as_array_mut())
        .flatten()
}

pub(super) fn sort_tool_results_by_call_order(messages: &mut [Value]) {
    let mut call_order: HashMap<String, usize> = HashMap::new();
    for message in messages {
        match role(message) {
            Some("assistant") => {
                let Some(tool_calls) = message.get("tool_calls").and_then(Value::as_array) else {
                    continue;
                };
                if tool_calls.is_empty() {
                    continue;
                }
                call_order.clear();
                for (index, call) in tool_calls.iter().enumerate() {
                    let id = call
                        .get("id")
                        .and_then(Value::as_str)
                        .or_else(|| {
                            call.get("function")
                                .and_then(|v| v.get("id"))
                                .and_then(Value::as_str)
                        })
                        .unwrap_or_default();
                    if !id.is_empty() {
                        call_order.insert(id.to_string(), index);
                    }
                }
            }
            Some("user") if !call_order.is_empty() => {
                let Some(blocks) = message
                    .get_mut("content_blocks")
                    .and_then(Value::as_array_mut)
                else {
                    continue;
                };
                let mut results: Vec<Value> = blocks
                    .iter()
                    .filter(|b| b.get("type").and_then(Value::as_str) == Some("tool_result"))
                    .cloned()
                    .collect();
                if results.len() < 2 {
                    continue;
                }
                results.sort_by_key(|b| {
                    b.get("tool_use_id")
                        .and_then(Value::as_str)
                        .and_then(|id| call_order.get(id).copied())
                        .unwrap_or(0)
                });
                let mut results = results.into_iter();
                for block in blocks {
                    if block.get("type").and_then(Value::as_str) == Some("tool_result") {
                        *block = results.next().expect("same number of result blocks");
                    }
                }
            }
            _ => {}
        }
    }
}

pub(super) fn drop_thinking_messages(messages: &[Value]) -> Vec<Value> {
    let Some(last_user) = last_user_index(messages) else {
        return messages.to_vec();
    };
    let mut out = Vec::with_capacity(messages.len());
    for (index, message) in messages.iter().enumerate() {
        if index >= last_user
            || matches!(
                role(message),
                // 2026-09-26: `developer` reaches this encoder unrenamed:
                // `build_msg_entries` keeps the role when `preserve_developer_role` is set,
                // which the chat path does for DeepSeek-V4.
                Some(
                    "user"
                        | "system"
                        | "developer"
                        | "tool"
                        | "latest_reminder"
                        | "direct_search_results"
                )
            )
        {
            out.push(message.clone());
            continue;
        }
        if let Some("assistant") = role(message) {
            let mut message = message.clone();
            if let Some(object) = message.as_object_mut() {
                object.remove("reasoning_content");
            }
            out.push(message);
        }
    }
    out
}