Skip to main content

litellm_rs/utils/data/validation/request_validator/
chat.rs

1use super::RequestValidator;
2use crate::core::models::openai::{ChatMessage, ContentPart, MessageContent, MessageRole};
3use crate::core::providers::ChatMessageContinuation;
4use crate::utils::error::gateway_error::{GatewayError, Result};
5
6impl RequestValidator {
7    /// Validate chat completion request
8    pub fn validate_chat_completion_request(
9        model: &str,
10        messages: &[ChatMessage],
11        max_tokens: Option<u32>,
12        temperature: Option<f32>,
13    ) -> Result<()> {
14        Self::validate_chat_completion_request_inner(model, messages, None, max_tokens, temperature)
15    }
16
17    /// Validate a chat request carrying typed per-message provider extensions.
18    pub(crate) fn validate_chat_completion_request_with_extensions(
19        model: &str,
20        messages: &[ChatMessage],
21        extensions: &[ChatMessageContinuation],
22        max_tokens: Option<u32>,
23        temperature: Option<f32>,
24    ) -> Result<()> {
25        if messages.len() != extensions.len() {
26            return Err(GatewayError::Validation(format!(
27                "Message extension length mismatch: expected {}, got {}",
28                messages.len(),
29                extensions.len()
30            )));
31        }
32        Self::validate_chat_completion_request_inner(
33            model,
34            messages,
35            Some(extensions),
36            max_tokens,
37            temperature,
38        )
39    }
40
41    fn validate_chat_completion_request_inner(
42        model: &str,
43        messages: &[ChatMessage],
44        extensions: Option<&[ChatMessageContinuation]>,
45        max_tokens: Option<u32>,
46        temperature: Option<f32>,
47    ) -> Result<()> {
48        // Validate model
49        Self::validate_model_name(model)?;
50
51        // Validate messages
52        if messages.is_empty() {
53            return Err(GatewayError::Validation(
54                "Messages cannot be empty".to_string(),
55            ));
56        }
57
58        for (i, message) in messages.iter().enumerate() {
59            Self::validate_chat_message_with_extension(
60                message,
61                extensions.and_then(|items| items.get(i)),
62                i,
63            )?;
64        }
65
66        // Validate max_tokens
67        if let Some(max_tokens) = max_tokens {
68            let max_output_tokens = 100_000;
69            if max_tokens == 0 {
70                return Err(GatewayError::Validation(
71                    "max_tokens must be greater than 0".to_string(),
72                ));
73            }
74            if max_tokens > max_output_tokens {
75                return Err(GatewayError::Validation(format!(
76                    "max_tokens cannot exceed {max_output_tokens}"
77                )));
78            }
79        }
80
81        // Validate temperature
82        if let Some(temperature) = temperature
83            && !(0.0..=2.0).contains(&temperature)
84        {
85            return Err(GatewayError::Validation(
86                "temperature must be between 0.0 and 2.0".to_string(),
87            ));
88        }
89
90        Ok(())
91    }
92
93    /// Validate chat message
94    pub(super) fn validate_chat_message(message: &ChatMessage, index: usize) -> Result<()> {
95        Self::validate_chat_message_with_extension(message, None, index)
96    }
97
98    fn validate_chat_message_with_extension(
99        message: &ChatMessage,
100        extension: Option<&ChatMessageContinuation>,
101        index: usize,
102    ) -> Result<()> {
103        // Validate role
104        match message.role {
105            MessageRole::System | MessageRole::Developer | MessageRole::User => {
106                // These roles should have content
107                if message.content.is_none() {
108                    return Err(GatewayError::Validation(format!(
109                        "Message at index {} with role {:?} must have content",
110                        index, message.role
111                    )));
112                }
113            }
114            MessageRole::Assistant => {
115                let has_tools = message
116                    .tool_calls
117                    .as_ref()
118                    .is_some_and(|calls| !calls.is_empty());
119                let has_extension = extension.is_some_and(|value| !value.is_empty());
120                if message.content.is_none()
121                    && !has_tools
122                    && message.function_call.is_none()
123                    && !has_extension
124                {
125                    return Err(GatewayError::Validation(format!(
126                        "Message at index {} with role {:?} must have content, tool calls, function_call, or a typed continuation",
127                        index, message.role
128                    )));
129                }
130            }
131            MessageRole::Function => {
132                // Function messages should have name and content
133                if message.name.is_none() {
134                    return Err(GatewayError::Validation(format!(
135                        "Function message at index {} must have a name",
136                        index
137                    )));
138                }
139                if message.content.is_none() {
140                    return Err(GatewayError::Validation(format!(
141                        "Function message at index {} must have content",
142                        index
143                    )));
144                }
145            }
146            MessageRole::Tool => {
147                // Tool messages should have tool_call_id and content
148                if message.tool_call_id.is_none() {
149                    return Err(GatewayError::Validation(format!(
150                        "Tool message at index {} must have tool_call_id",
151                        index
152                    )));
153                }
154                if message.content.is_none() {
155                    return Err(GatewayError::Validation(format!(
156                        "Tool message at index {} must have content",
157                        index
158                    )));
159                }
160            }
161        }
162
163        if extension.is_some_and(|value| !value.is_empty())
164            && message.role != MessageRole::Assistant
165        {
166            return Err(GatewayError::Validation(format!(
167                "Typed Anthropic continuation at message index {} requires assistant role",
168                index
169            )));
170        }
171
172        // Validate content if present
173        if let Some(content) = &message.content {
174            Self::validate_message_content(content, index)?;
175        }
176
177        // Validate name if present
178        if let Some(name) = &message.name {
179            Self::validate_function_name(name)?;
180        }
181
182        Ok(())
183    }
184
185    /// Validate message content
186    pub(super) fn validate_message_content(content: &MessageContent, index: usize) -> Result<()> {
187        match content {
188            MessageContent::Text(text) => {
189                if text.trim().is_empty() {
190                    return Err(GatewayError::Validation(format!(
191                        "Text content at message index {} cannot be empty",
192                        index
193                    )));
194                }
195                if text.len() > 1_000_000 {
196                    return Err(GatewayError::Validation(format!(
197                        "Text content at message index {} is too long (max 1M characters)",
198                        index
199                    )));
200                }
201            }
202            MessageContent::Parts(parts) => {
203                if parts.is_empty() {
204                    return Err(GatewayError::Validation(format!(
205                        "Content parts at message index {} cannot be empty",
206                        index
207                    )));
208                }
209                for (part_index, part) in parts.iter().enumerate() {
210                    Self::validate_content_part(part, index, part_index)?;
211                }
212            }
213        }
214
215        Ok(())
216    }
217
218    /// Validate content part
219    pub(super) fn validate_content_part(
220        part: &ContentPart,
221        message_index: usize,
222        part_index: usize,
223    ) -> Result<()> {
224        match part {
225            ContentPart::Text { text } => {
226                if text.trim().is_empty() {
227                    return Err(GatewayError::Validation(format!(
228                        "Text part at message {} part {} cannot be empty",
229                        message_index, part_index
230                    )));
231                }
232            }
233            ContentPart::ImageUrl { image_url } => {
234                Self::validate_image_url(&image_url.url)?;
235                if let Some(detail) = &image_url.detail
236                    && !["low", "high", "auto"].contains(&detail.as_str())
237                {
238                    return Err(GatewayError::Validation(
239                        "Image detail must be 'low', 'high', or 'auto'".to_string(),
240                    ));
241                }
242            }
243            ContentPart::Audio { audio } => {
244                Self::validate_audio_data(&audio.data)?;
245                Self::validate_audio_format(&audio.format)?;
246            }
247            ContentPart::Image {
248                source,
249                detail,
250                image_url,
251            } => {
252                if source.media_type.trim().is_empty()
253                    || !source.media_type.to_ascii_lowercase().starts_with("image/")
254                {
255                    return Err(GatewayError::Validation(format!(
256                        "Image part at message {} part {} must have image/* media_type",
257                        message_index, part_index
258                    )));
259                }
260                Self::validate_base64_payload(&source.data, "image")?;
261                if let Some(detail) = detail
262                    && !["low", "high", "auto"].contains(&detail.as_str())
263                {
264                    return Err(GatewayError::Validation(
265                        "Image detail must be 'low', 'high', or 'auto'".to_string(),
266                    ));
267                }
268                if let Some(url) = image_url {
269                    Self::validate_image_url(&url.url)?;
270                }
271            }
272            ContentPart::Document { source, .. } => {
273                if source.media_type.trim().is_empty() {
274                    return Err(GatewayError::Validation(format!(
275                        "Document part at message {} part {} must have media_type",
276                        message_index, part_index
277                    )));
278                }
279                Self::validate_base64_payload(&source.data, "document")?;
280            }
281            ContentPart::ToolResult { tool_use_id, .. } => {
282                if tool_use_id.trim().is_empty() {
283                    return Err(GatewayError::Validation(format!(
284                        "Tool result at message {} part {} must have non-empty tool_use_id",
285                        message_index, part_index
286                    )));
287                }
288            }
289            ContentPart::ToolUse { id, name, .. } => {
290                if id.trim().is_empty() || name.trim().is_empty() {
291                    return Err(GatewayError::Validation(format!(
292                        "Tool use at message {} part {} must have non-empty id/name",
293                        message_index, part_index
294                    )));
295                }
296            }
297        }
298
299        Ok(())
300    }
301}