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: Input validation for a chat request, run by `chat::mod.rs` before any
//! other work.
//!
//! Owner: server chat API.
//! Invariants: chat requests contain 1..=32,768 messages.

use axum::http::StatusCode;
use axum::response::Response;

use crate::ir::ChatRequest;

use super::compact::{openai_error_response, openai_error_response_with_param};

const MAX_MESSAGES_PER_CHAT_REQUEST: usize = 32_768;

/// 2026-09-26: Check the request envelope: 1..=32,768 messages, temperature in [0, 2],
/// top_p in (0, 1], max_tokens >= 1, n in 1..=128, a known `tool_choice` mode, and
/// tools present when `tool_choice` is `required`. Every failure is an OpenAI-style
/// 400 response.
#[allow(clippy::result_large_err)]
pub(super) fn validate_input(req: &ChatRequest) -> Result<(), Response> {
    if req.messages.is_empty() {
        return Err(openai_error_response_with_param(
            StatusCode::BAD_REQUEST,
            "messages must contain at least one message".into(),
            Some("messages"),
            None,
        ));
    }
    if req.messages.len() > MAX_MESSAGES_PER_CHAT_REQUEST {
        return Err(openai_error_response_with_param(
            StatusCode::BAD_REQUEST,
            format!("messages array exceeds maximum length ({MAX_MESSAGES_PER_CHAT_REQUEST})"),
            Some("messages"),
            None,
        ));
    }
    if let Some(t) = req.sampling.temperature
        && (!(0.0..=2.0).contains(&t))
    {
        return Err(openai_error_response_with_param(
            StatusCode::BAD_REQUEST,
            "temperature must be between 0 and 2".into(),
            Some("temperature"),
            None,
        ));
    }
    if let Some(p) = req.sampling.top_p
        && (p <= 0.0 || p > 1.0)
    {
        return Err(openai_error_response_with_param(
            StatusCode::BAD_REQUEST,
            "top_p must be between 0 (exclusive) and 1".into(),
            Some("top_p"),
            None,
        ));
    }
    if req.max_tokens == 0 {
        return Err(openai_error_response_with_param(
            StatusCode::BAD_REQUEST,
            "max_tokens must be at least 1".into(),
            Some("max_tokens"),
            None,
        ));
    }
    // 2026-09-26: The blocking path allocates `Vec::with_capacity(n)` and runs n
    // sequential inferences, so n is bounded here. `/v1/completions` applies the same
    // 1..=128 bound.
    if req.n == 0 || req.n > 128 {
        return Err(openai_error_response_with_param(
            StatusCode::BAD_REQUEST,
            format!("n must be between 1 and 128, got {}", req.n),
            Some("n"),
            None,
        ));
    }
    if let Some(crate::tool_parser::ToolChoice::Mode(ref s)) = req.tool_choice {
        if !["auto", "none", "required"].contains(&s.as_str()) {
            return Err(openai_error_response(
                StatusCode::BAD_REQUEST,
                format!(
                    "Invalid tool_choice value: '{s}'. Must be 'auto', 'none', 'required', or a function object."
                ),
            ));
        }
        if s == "required" && req.tools.is_empty() {
            return Err(openai_error_response(
                StatusCode::BAD_REQUEST,
                "tool_choice is 'required' but no tools were provided".into(),
            ));
        }
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    fn request_with_message_count(message_count: usize) -> ChatRequest {
        ChatRequest {
            model: "test".into(),
            messages: vec![crate::ir::Message::synthetic_system(String::new()); message_count],
            tools: Vec::new(),
            tool_choice: None,
            sampling: crate::ir::SamplingParams::default(),
            max_tokens: 1,
            min_tokens: 0,
            stop: Vec::new(),
            stream: false,
            n: 1,
            response_format: None,
            thinking: crate::ir::ThinkingDirective::Unspecified,
            reasoning_effort: None,
            preserve_thinking: None,
            template_thinking: None,
            repetition_detection: None,
            adapter: None,
            src_lang: None,
            tgt_lang: None,
            num_beams: None,
            length_penalty: None,
            early_stopping: None,
            logit_bias: Vec::new(),
            top_logprobs: None,
            seed: None,
            timeout_secs: None,
            return_token_ids: false,
            lookup_tenant: None,
        }
    }

    #[test]
    fn accepts_the_32768_message_limit() {
        assert!(validate_input(&request_with_message_count(MAX_MESSAGES_PER_CHAT_REQUEST)).is_ok());
    }

    #[test]
    fn rejects_more_than_32768_messages() {
        let response = validate_input(&request_with_message_count(
            MAX_MESSAGES_PER_CHAT_REQUEST + 1,
        ))
        .unwrap_err();

        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
    }
}