litellm_rs/utils/data/validation/request_validator/
chat.rs1use 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 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 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 Self::validate_model_name(model)?;
50
51 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 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 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 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 match message.role {
105 MessageRole::System | MessageRole::Developer | MessageRole::User => {
106 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 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 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 if let Some(content) = &message.content {
174 Self::validate_message_content(content, index)?;
175 }
176
177 if let Some(name) = &message.name {
179 Self::validate_function_name(name)?;
180 }
181
182 Ok(())
183 }
184
185 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 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}