use std::{borrow::Cow, fmt::Display, sync::LazyLock};
use dynamo_protocols::types::{
ChatCompletionTool, ChatCompletionToolType, CreateChatCompletionRequest, FunctionObject,
};
use dynamo_runtime::config::{
env_is_truthy, environment_names::llm::DYN_IGNORE_OPENAI_FE_UNSUPPORTED_FIELDS,
};
use serde_json::Value;
use super::{
common_ext::{CommonExt, CommonExtProvider, extract_guided_decoding_options},
tools::{ToolChoiceError, validate_openai_tool_choice},
};
pub const MIN_TEMPERATURE: f32 = 0.0;
pub const MAX_TEMPERATURE: f32 = 2.0;
pub const TEMPERATURE_RANGE: (f32, f32) = (MIN_TEMPERATURE, MAX_TEMPERATURE);
pub const MIN_TOP_P: f32 = 0.0;
pub const MAX_TOP_P: f32 = 1.0;
pub const MIN_MIN_P: f32 = 0.0;
pub const MAX_MIN_P: f32 = 1.0;
pub const MIN_P_RANGE: (f32, f32) = (MIN_MIN_P, MAX_MIN_P);
pub const MIN_FREQUENCY_PENALTY: f32 = -2.0;
pub const MAX_FREQUENCY_PENALTY: f32 = 2.0;
pub const FREQUENCY_PENALTY_RANGE: (f32, f32) = (MIN_FREQUENCY_PENALTY, MAX_FREQUENCY_PENALTY);
pub const MIN_PRESENCE_PENALTY: f32 = -2.0;
pub const MAX_PRESENCE_PENALTY: f32 = 2.0;
pub const PRESENCE_PENALTY_RANGE: (f32, f32) = (MIN_PRESENCE_PENALTY, MAX_PRESENCE_PENALTY);
pub const MIN_LENGTH_PENALTY: f32 = -2.0;
pub const MAX_LENGTH_PENALTY: f32 = 2.0;
pub const LENGTH_PENALTY_RANGE: (f32, f32) = (MIN_LENGTH_PENALTY, MAX_LENGTH_PENALTY);
pub const MIN_TOP_LOGPROBS: u8 = 0;
pub const MAX_TOP_LOGPROBS: u8 = 20;
pub const MIN_LOGPROBS: u8 = 0;
pub const MAX_LOGPROBS: u8 = 5;
pub const MIN_N: u8 = 1;
pub const MAX_N: u8 = 128;
pub const N_RANGE: (u8, u8) = (MIN_N, MAX_N);
pub const MAX_TOTAL_CHOICES: usize = 128;
pub const MIN_LOGIT_BIAS: f32 = -100.0;
pub const MAX_LOGIT_BIAS: f32 = 100.0;
pub const MIN_BEST_OF: u8 = 0;
pub const MAX_BEST_OF: u8 = 20;
pub const BEST_OF_RANGE: (u8, u8) = (MIN_BEST_OF, MAX_BEST_OF);
pub const MAX_STOP_SEQUENCES: usize = 32;
pub const MAX_TOOLS: usize = 1536;
pub const MAX_FUNCTION_NAME_LENGTH: usize = 128;
pub const MIN_REPETITION_PENALTY: f32 = 0.0;
pub const MAX_REPETITION_PENALTY: f32 = 2.0;
pub const PASSTHROUGH_EXTRA_FIELDS: &[&str] = &[
"cache_salt",
"stop_token_ids",
"detokenize",
"allowed_token_ids",
"bad_words_token_ids",
"logprob_token_ids",
];
pub(super) fn deserialize_extra_fields<'de, D>(
deserializer: D,
) -> Result<std::collections::HashMap<String, serde_json::Value>, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::Deserialize;
let mut fields =
std::collections::HashMap::<String, serde_json::Value>::deserialize(deserializer)?;
fields
.retain(|key, value| !value.is_null() || !PASSTHROUGH_EXTRA_FIELDS.contains(&key.as_str()));
Ok(fields)
}
static IGNORE_OPENAI_FE_UNSUPPORTED_FIELDS: LazyLock<bool> =
LazyLock::new(|| env_is_truthy(DYN_IGNORE_OPENAI_FE_UNSUPPORTED_FIELDS));
pub fn validate_no_unsupported_fields(
unsupported_fields: &std::collections::HashMap<String, serde_json::Value>,
) -> Result<(), anyhow::Error> {
validate_no_unsupported_fields_with_ignore(
unsupported_fields,
*IGNORE_OPENAI_FE_UNSUPPORTED_FIELDS,
)
}
fn validate_no_unsupported_fields_with_ignore(
unsupported_fields: &std::collections::HashMap<String, serde_json::Value>,
ignore_unsupported_fields: bool,
) -> Result<(), anyhow::Error> {
let unknown: Vec<_> = unsupported_fields
.keys()
.filter(|k| !PASSTHROUGH_EXTRA_FIELDS.contains(&k.as_str()))
.map(|s| format!("`{}`", s))
.collect();
if !unknown.is_empty() && !ignore_unsupported_fields {
anyhow::bail!("Unsupported parameter(s): {}", unknown.join(", "));
}
if let Some(value) = unsupported_fields.get("cache_salt")
&& !value.is_string()
{
anyhow::bail!("`cache_salt` must be a string");
}
if let Some(value) = unsupported_fields.get("stop_token_ids") {
let token_ids: Vec<crate::types::TokenIdType> = serde_json::from_value(value.clone())
.map_err(|_| anyhow::anyhow!("`stop_token_ids` must be an array of token IDs"))?;
if token_ids.len() > MAX_STOP_SEQUENCES {
return Err(crate::protocols::common::invalid_argument_error(format!(
"Maximum of {} stop token IDs allowed, got {}",
MAX_STOP_SEQUENCES,
token_ids.len()
)));
}
}
if let Some(value) = unsupported_fields.get("detokenize")
&& !value.is_boolean()
{
anyhow::bail!("`detokenize` must be a boolean");
}
if let Some(value) = unsupported_fields.get("allowed_token_ids") {
serde_json::from_value::<Vec<crate::types::TokenIdType>>(value.clone())
.map_err(|_| anyhow::anyhow!("`allowed_token_ids` must be an array of token IDs"))?;
}
if let Some(value) = unsupported_fields.get("bad_words_token_ids") {
serde_json::from_value::<Vec<Vec<crate::types::TokenIdType>>>(value.clone()).map_err(
|_| anyhow::anyhow!("`bad_words_token_ids` must be an array of token ID arrays"),
)?;
}
if let Some(value) = unsupported_fields.get("logprob_token_ids") {
serde_json::from_value::<Vec<crate::types::TokenIdType>>(value.clone())
.map_err(|_| anyhow::anyhow!("`logprob_token_ids` must be an array of token IDs"))?;
}
Ok(())
}
pub fn validate_response_format(
response_format: &Option<dynamo_protocols::types::ResponseFormat>,
) -> Result<(), anyhow::Error> {
use dynamo_protocols::types::ResponseFormat;
let Some(fmt) = response_format else {
return Ok(());
};
match fmt {
ResponseFormat::Text => Ok(()),
ResponseFormat::JsonObject => Ok(()),
ResponseFormat::JsonSchema { json_schema } => {
if json_schema.name.is_empty() {
anyhow::bail!("`response_format.json_schema.name` cannot be empty");
}
if json_schema.schema.is_null() {
anyhow::bail!(
"`response_format.json_schema.schema` is required when `response_format.type` is `json_schema`"
);
}
if !json_schema.schema.is_object() {
anyhow::bail!(
"`response_format.json_schema.schema` must be a JSON object, got {}",
match &json_schema.schema {
serde_json::Value::Array(_) => "array",
serde_json::Value::String(_) => "string",
serde_json::Value::Number(_) => "number",
serde_json::Value::Bool(_) => "boolean",
_ => "non-object",
}
);
}
Ok(())
}
}
}
pub fn validate_response_format_conflicts(
response_format: &Option<dynamo_protocols::types::ResponseFormat>,
common: &CommonExt,
) -> Result<(), anyhow::Error> {
use dynamo_protocols::types::ResponseFormat;
let has_response_format_constraint = response_format
.as_ref()
.is_some_and(|format| !matches!(format, ResponseFormat::Text));
let has_explicit_guided_decoding = common.guided_json.is_some()
|| common.guided_regex.is_some()
|| common
.guided_choice
.as_ref()
.is_some_and(|choices| !choices.is_empty())
|| common.guided_grammar.is_some();
if has_response_format_constraint && has_explicit_guided_decoding {
anyhow::bail!(
"`response_format` cannot be used together with `guided_json`, `guided_regex`, \
`guided_choice`, or `guided_grammar`"
);
}
Ok(())
}
pub fn validate_temperature(temperature: Option<f32>) -> Result<(), anyhow::Error> {
if let Some(temp) = temperature
&& !(MIN_TEMPERATURE..=MAX_TEMPERATURE).contains(&temp)
{
anyhow::bail!(
"Temperature must be between {} and {}, got {}",
MIN_TEMPERATURE,
MAX_TEMPERATURE,
temp
);
}
Ok(())
}
pub fn validate_top_p(top_p: Option<f32>) -> Result<(), anyhow::Error> {
if let Some(p) = top_p
&& !(p.is_finite() && p > MIN_TOP_P && p <= MAX_TOP_P)
{
anyhow::bail!(
"Top_p must be between {} and {}, got {}",
MIN_TOP_P,
MAX_TOP_P,
p
);
}
Ok(())
}
pub fn validate_top_k(top_k: Option<i32>) -> Result<(), anyhow::Error> {
match top_k {
None => Ok(()),
Some(k) if k >= -1 => Ok(()),
_ => anyhow::bail!("Top_k must be null or greater than or equal to -1"),
}
}
pub fn validate_temperature_top_p_exclusion(
temperature: Option<f32>,
top_p: Option<f32>,
) -> Result<(), anyhow::Error> {
match (temperature, top_p) {
(Some(t), Some(p)) if t != 1.0 && p != 1.0 => {
anyhow::bail!("Only one of temperature or top_p should be set (not both)");
}
_ => Ok(()),
}
}
pub fn validate_frequency_penalty(frequency_penalty: Option<f32>) -> Result<(), anyhow::Error> {
if let Some(penalty) = frequency_penalty
&& !(MIN_FREQUENCY_PENALTY..=MAX_FREQUENCY_PENALTY).contains(&penalty)
{
anyhow::bail!(
"Frequency penalty must be between {} and {}, got {}",
MIN_FREQUENCY_PENALTY,
MAX_FREQUENCY_PENALTY,
penalty
);
}
Ok(())
}
pub fn validate_presence_penalty(presence_penalty: Option<f32>) -> Result<(), anyhow::Error> {
if let Some(penalty) = presence_penalty
&& !(MIN_PRESENCE_PENALTY..=MAX_PRESENCE_PENALTY).contains(&penalty)
{
anyhow::bail!(
"Presence penalty must be between {} and {}, got {}",
MIN_PRESENCE_PENALTY,
MAX_PRESENCE_PENALTY,
penalty
);
}
Ok(())
}
pub fn validate_repetition_penalty(repetition_penalty: Option<f32>) -> Result<(), anyhow::Error> {
if let Some(penalty) = repetition_penalty
&& (penalty <= MIN_REPETITION_PENALTY || penalty > MAX_REPETITION_PENALTY)
{
anyhow::bail!(
"Repetition penalty must be between {} and {}, got {}",
MIN_REPETITION_PENALTY,
MAX_REPETITION_PENALTY,
penalty
);
}
Ok(())
}
pub fn validate_min_p(min_p: Option<f32>) -> Result<(), anyhow::Error> {
if let Some(p) = min_p
&& !(MIN_MIN_P..=MAX_MIN_P).contains(&p)
{
anyhow::bail!(
"Min_p must be between {} and {}, got {}",
MIN_MIN_P,
MAX_MIN_P,
p
);
}
Ok(())
}
pub fn validate_logit_bias(
logit_bias: &Option<std::collections::HashMap<String, serde_json::Value>>,
) -> Result<(), anyhow::Error> {
let logit_bias = match logit_bias {
Some(val) => val,
None => return Ok(()),
};
for (token, bias_value) in logit_bias {
token.parse::<u32>().map_err(|_| {
anyhow::anyhow!("Logit bias key must be a non-negative 32-bit integer token id")
})?;
let bias = bias_value.as_f64().ok_or_else(|| {
anyhow::anyhow!(
"Logit bias value for token '{}' must be a number, got {:?}",
token,
bias_value
)
})? as f32;
if !(MIN_LOGIT_BIAS..=MAX_LOGIT_BIAS).contains(&bias) {
anyhow::bail!(
"Logit bias for token '{}' must be between {} and {}, got {}",
token,
MIN_LOGIT_BIAS,
MAX_LOGIT_BIAS,
bias
);
}
}
Ok(())
}
pub fn validate_n(n: Option<u8>) -> Result<(), anyhow::Error> {
if let Some(value) = n
&& !(MIN_N..=MAX_N).contains(&value)
{
anyhow::bail!("n must be between {} and {}, got {}", MIN_N, MAX_N, value);
}
Ok(())
}
pub fn validate_total_choices(batch_size: usize, n: u8) -> Result<(), anyhow::Error> {
let total_choices = batch_size * (n as usize);
if total_choices > MAX_TOTAL_CHOICES {
anyhow::bail!(
"Total choices (batch_size × n = {} × {} = {}) exceeds maximum of {}",
batch_size,
n,
total_choices,
MAX_TOTAL_CHOICES
);
}
Ok(())
}
pub fn validate_n_with_temperature(
n: Option<u8>,
temperature: Option<f32>,
) -> Result<(), anyhow::Error> {
if let Some(n_value) = n
&& n_value > 1
{
let temp = temperature.unwrap_or(1.0);
if temp == 0.0 {
anyhow::bail!(
"When n > 1, temperature must be greater than 0 to ensure diverse outputs. Got n={}, temperature={}",
n_value,
temp
);
}
}
Ok(())
}
pub fn validate_model(model: &str) -> Result<(), anyhow::Error> {
if model.trim().is_empty() {
anyhow::bail!("Model cannot be empty");
}
Ok(())
}
pub fn validate_user(user: Option<&str>) -> Result<(), anyhow::Error> {
if let Some(user_id) = user
&& user_id.trim().is_empty()
{
anyhow::bail!("User ID cannot be empty");
}
Ok(())
}
pub fn validate_stop(stop: &Option<dynamo_protocols::types::Stop>) -> Result<(), anyhow::Error> {
if let Some(stop_value) = stop {
match stop_value {
dynamo_protocols::types::Stop::String(s) => {
if s.is_empty() {
anyhow::bail!("Stop sequence cannot be empty");
}
}
dynamo_protocols::types::Stop::StringArray(sequences) => {
if sequences.is_empty() {
anyhow::bail!("Stop sequences array cannot be empty");
}
if sequences.len() > MAX_STOP_SEQUENCES {
return Err(crate::protocols::common::invalid_argument_error(format!(
"Maximum of {} stop sequences allowed, got {}",
MAX_STOP_SEQUENCES,
sequences.len()
)));
}
for (i, sequence) in sequences.iter().enumerate() {
if sequence.is_empty() {
anyhow::bail!("Stop sequence at index {} cannot be empty", i);
}
}
}
dynamo_protocols::types::Stop::TokenIdArray(token_ids) => {
if token_ids.is_empty() {
anyhow::bail!("Stop token IDs array cannot be empty");
}
if token_ids.len() > MAX_STOP_SEQUENCES {
return Err(crate::protocols::common::invalid_argument_error(format!(
"Maximum of {} stop token IDs allowed, got {}",
MAX_STOP_SEQUENCES,
token_ids.len()
)));
}
}
}
}
Ok(())
}
pub fn validate_messages(
messages: &[dynamo_protocols::types::ChatCompletionRequestMessage],
) -> Result<(), anyhow::Error> {
if messages.is_empty() {
anyhow::bail!("Messages array cannot be empty");
}
for (message_index, message) in messages.iter().enumerate() {
if let dynamo_protocols::types::ChatCompletionRequestMessage::Assistant(assistant) = message
&& let Some(tool_calls) = &assistant.tool_calls
{
for (tool_call_index, tool_call) in tool_calls.iter().enumerate() {
validate_json_object_string(
&tool_call.function.arguments,
format!(
"`messages[{message_index}].tool_calls[{tool_call_index}].function.arguments`"
),
)?;
}
}
}
Ok(())
}
fn validate_json_object_string(value: &str, field: String) -> Result<(), anyhow::Error> {
if value.trim().is_empty() {
return Ok(());
}
let parsed: serde_json::Value = serde_json::from_str(value)
.map_err(|error| anyhow::anyhow!("{field} must be a valid JSON object string: {error}"))?;
if !parsed.is_object() {
anyhow::bail!("{field} must be a valid JSON object string");
}
Ok(())
}
pub fn validate_top_logprobs(top_logprobs: Option<u8>) -> Result<(), anyhow::Error> {
if let Some(value) = top_logprobs
&& !(0..=20).contains(&value)
{
anyhow::bail!(
"Top_logprobs must be between 0 and {}, got {}",
MAX_TOP_LOGPROBS,
value
);
}
Ok(())
}
pub(crate) fn validated_effective_tools(
request: &CreateChatCompletionRequest,
) -> Result<Cow<'_, [ChatCompletionTool]>, anyhow::Error> {
let dynamic_tools = request
.dynamic_system_tools()
.enumerate()
.map(|(index, tool)| normalize_dynamic_system_tool(tool, index))
.collect::<Result<Vec<_>, _>>()?;
if dynamic_tools.is_empty() && request.tools.as_ref().is_some_and(|tools| tools.is_empty()) {
anyhow::bail!("tools array cannot be empty; omit the field entirely to disable tool use");
}
let tools = if dynamic_tools.is_empty() {
Cow::Borrowed(request.tools.as_deref().unwrap_or_default())
} else {
let mut tools = request.tools.clone().unwrap_or_default();
tools.extend(dynamic_tools);
Cow::Owned(tools)
};
validate_tools(&Some(tools.as_ref()))?;
Ok(tools)
}
fn normalize_dynamic_system_tool(
tool: &Value,
index: usize,
) -> Result<ChatCompletionTool, anyhow::Error> {
let object = tool.as_object().ok_or_else(|| {
anyhow::anyhow!("dynamic system tool at index {index} must be a JSON object")
})?;
let function = match (object.get("type"), object.get("function")) {
(Some(Value::String(kind)), Some(Value::Object(function))) if kind == "function" => {
function
}
(Some(kind), _) if kind.as_str() != Some("function") => {
anyhow::bail!("dynamic system tool at index {index} must have type=\"function\"");
}
(Some(_), _) => {
anyhow::bail!(
"dynamic system tool at index {index} with type=\"function\" needs a function object"
);
}
(None, Some(_)) => {
anyhow::bail!(
"dynamic system tool at index {index} with a function field needs type=\"function\""
);
}
(None, None) => object,
};
let name = function
.get("name")
.and_then(Value::as_str)
.filter(|name| !name.is_empty())
.ok_or_else(|| {
anyhow::anyhow!("dynamic system tool at index {index} needs a non-empty string name")
})?;
let description = optional_dynamic_string(function.get("description"), "description", index)?;
let parameters = match function.get("parameters") {
None | Some(Value::Null) => None,
Some(parameters @ Value::Object(_)) => Some(parameters.clone()),
Some(_) => {
anyhow::bail!(
"dynamic system tool at index {index} parameters must be a JSON Schema object"
);
}
};
let strict = match function.get("strict") {
None | Some(Value::Null) => None,
Some(Value::Bool(strict)) => Some(*strict),
Some(_) => {
anyhow::bail!("dynamic system tool at index {index} strict must be a boolean");
}
};
Ok(ChatCompletionTool {
r#type: ChatCompletionToolType::Function,
function: FunctionObject {
name: name.to_string(),
description,
parameters,
strict,
},
})
}
fn optional_dynamic_string(
value: Option<&Value>,
field: &str,
index: usize,
) -> Result<Option<String>, anyhow::Error> {
match value {
None | Some(Value::Null) => Ok(None),
Some(Value::String(value)) => Ok(Some(value.clone())),
Some(_) => anyhow::bail!("dynamic system tool at index {index} {field} must be a string"),
}
}
pub fn validate_tools(
tools: &Option<&[dynamo_protocols::types::ChatCompletionTool]>,
) -> Result<(), anyhow::Error> {
let tools = match tools {
Some(val) => val,
None => return Ok(()),
};
if tools.len() > MAX_TOOLS {
anyhow::bail!(
"Maximum of {} tools are supported, got {}",
MAX_TOOLS,
tools.len()
);
}
for (i, tool) in tools.iter().enumerate() {
if tool.function.name.len() > MAX_FUNCTION_NAME_LENGTH {
anyhow::bail!(
"Function name at index {} exceeds {} character limit, got {} characters",
i,
MAX_FUNCTION_NAME_LENGTH,
tool.function.name.len()
);
}
if tool.function.name.trim().is_empty() {
anyhow::bail!("Function name at index {} cannot be empty", i);
}
if !tool
.function
.name
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
{
anyhow::bail!(
"Function at index {} has an invalid name: \"{}\". \
Only a-z, A-Z, 0-9, underscores, and dashes are allowed.",
i,
tool.function.name,
);
}
if let Some(parameters) = &tool.function.parameters
&& !parameters.is_object()
{
anyhow::bail!(
"Function parameters at index {} for \"{}\" must be a JSON Schema object",
i,
tool.function.name,
);
}
super::strict_schema::validate_strict_function(&tool.function)?;
}
Ok(())
}
pub fn validate_tool_choice(
tool_choice: &Option<dynamo_protocols::types::ChatCompletionToolChoiceOption>,
tools: Option<&[dynamo_protocols::types::ChatCompletionTool]>,
) -> Result<(), anyhow::Error> {
use dynamo_protocols::types::ChatCompletionToolChoiceOption;
match validate_openai_tool_choice(tool_choice.as_ref(), tools) {
Ok(()) => Ok(()),
Err(ToolChoiceError::EmptyTools) => {
anyhow::bail!("tool_choice is \"required\" but tools is empty")
}
Err(ToolChoiceError::MissingTools) => match tool_choice {
Some(ChatCompletionToolChoiceOption::Required) => {
anyhow::bail!("tool_choice is \"required\" but tools is empty")
}
Some(ChatCompletionToolChoiceOption::Named(named)) => anyhow::bail!(
"tool named \"{}\" in tool_choice is not present in tools",
named.function.name
),
_ => Err(ToolChoiceError::MissingTools.into()),
},
Err(ToolChoiceError::ToolNotFound(name)) => {
anyhow::bail!("tool named \"{name}\" in tool_choice is not present in tools")
}
Err(error) => Err(error.into()),
}
}
pub fn validate_reasoning_effort(
_reasoning_effort: &Option<dynamo_protocols::types::ReasoningEffort>,
) -> Result<(), anyhow::Error> {
Ok(())
}
pub fn validate_service_tier(
_service_tier: &Option<dynamo_protocols::types::ServiceTier>,
) -> Result<(), anyhow::Error> {
Ok(())
}
pub fn validate_prompt(prompt: &dynamo_protocols::types::Prompt) -> Result<(), anyhow::Error> {
match prompt {
dynamo_protocols::types::Prompt::String(s) => {
if s.is_empty() {
anyhow::bail!("Prompt string cannot be empty");
}
}
dynamo_protocols::types::Prompt::StringArray(arr) => {
if arr.is_empty() {
anyhow::bail!("Prompt string array cannot be empty");
}
for (i, s) in arr.iter().enumerate() {
if s.is_empty() {
anyhow::bail!("Prompt string at index {} cannot be empty", i);
}
}
}
dynamo_protocols::types::Prompt::IntegerArray(arr) => {
if arr.is_empty() {
anyhow::bail!("Prompt integer array cannot be empty");
}
}
dynamo_protocols::types::Prompt::ArrayOfIntegerArray(arr) => {
if arr.is_empty() {
anyhow::bail!("Prompt array of integer arrays cannot be empty");
}
for (i, inner_arr) in arr.iter().enumerate() {
if inner_arr.is_empty() {
anyhow::bail!("Prompt integer array at index {} cannot be empty", i);
}
}
}
}
Ok(())
}
pub fn validate_prompt_or_embeds(
prompt: Option<&dynamo_protocols::types::Prompt>,
prompt_embeds: Option<&str>,
) -> Result<(), anyhow::Error> {
if prompt.is_none() && prompt_embeds.is_none() {
anyhow::bail!("At least one of 'prompt' or 'prompt_embeds' must be provided");
}
if let Some(embeds) = prompt_embeds {
validate_prompt_embeds_format(embeds)?;
} else if let Some(p) = prompt {
validate_prompt(p)?;
}
Ok(())
}
fn validate_prompt_embeds_format(embeds: &str) -> Result<(), anyhow::Error> {
use base64::{Engine as _, engine::general_purpose};
let decoded = general_purpose::STANDARD
.decode(embeds)
.map_err(|_| anyhow::anyhow!("prompt_embeds must be valid base64-encoded data"))?;
const MIN_SIZE: usize = 100;
if decoded.len() < MIN_SIZE {
anyhow::bail!(
"prompt_embeds decoded data must be at least {MIN_SIZE} bytes, got {} bytes",
decoded.len()
);
}
const MAX_SIZE: usize = 10 * 1024 * 1024;
if decoded.len() > MAX_SIZE {
anyhow::bail!(
"prompt_embeds decoded data exceeds maximum size of 10MB, got {} bytes",
decoded.len()
);
}
Ok(())
}
pub fn validate_prompt_embeds(prompt_embeds: Option<&str>) -> Result<(), anyhow::Error> {
if let Some(embeds) = prompt_embeds {
validate_prompt_embeds_format(embeds)?;
}
Ok(())
}
pub fn validate_logprobs(logprobs: Option<u8>) -> Result<(), anyhow::Error> {
if let Some(value) = logprobs
&& !(MIN_LOGPROBS..=MAX_LOGPROBS).contains(&value)
{
anyhow::bail!(
"Logprobs must be between 0 and {}, got {}",
MAX_LOGPROBS,
value
);
}
Ok(())
}
pub fn validate_best_of(best_of: Option<u8>, n: Option<u8>) -> Result<(), anyhow::Error> {
if let Some(best_of_value) = best_of {
if !(MIN_BEST_OF..=MAX_BEST_OF).contains(&best_of_value) {
anyhow::bail!(
"Best_of must be between 0 and {}, got {}",
MAX_BEST_OF,
best_of_value
);
}
if let Some(n_value) = n
&& best_of_value < n_value
{
anyhow::bail!(
"Best_of must be greater than or equal to n, got best_of={} and n={}",
best_of_value,
n_value
);
}
}
Ok(())
}
pub fn validate_suffix(suffix: Option<&str>) -> Result<(), anyhow::Error> {
if let Some(suffix_str) = suffix {
if suffix_str.len() > 10000 {
anyhow::bail!("Suffix is too long, maximum 10000 characters");
}
}
Ok(())
}
const MAX_OUTPUT_TOKENS: u32 = 1_048_576;
pub fn validate_max_tokens(max_tokens: Option<u32>) -> Result<(), anyhow::Error> {
if let Some(tokens) = max_tokens
&& tokens == 0
{
anyhow::bail!("Max tokens must be greater than 0, got {}", tokens);
}
if let Some(tokens) = max_tokens
&& tokens > MAX_OUTPUT_TOKENS
{
anyhow::bail!(
"Max tokens must not exceed {}, got {}",
MAX_OUTPUT_TOKENS,
tokens
);
}
Ok(())
}
pub fn validate_max_completion_tokens(
max_completion_tokens: Option<u32>,
) -> Result<(), anyhow::Error> {
if let Some(tokens) = max_completion_tokens
&& tokens == 0
{
anyhow::bail!(
"Max completion tokens must be greater than 0, got {}",
tokens
);
}
if let Some(tokens) = max_completion_tokens
&& tokens > MAX_OUTPUT_TOKENS
{
anyhow::bail!(
"Max completion tokens must not exceed {}, got {}",
MAX_OUTPUT_TOKENS,
tokens
);
}
Ok(())
}
pub fn validate_range<T>(value: Option<T>, range: &(T, T)) -> anyhow::Result<Option<T>>
where
T: PartialOrd + Display,
{
if value.is_none() {
return Ok(None);
}
let value = value.unwrap();
if value < range.0 || value > range.1 {
anyhow::bail!("Value {} is out of range [{}, {}]", value, range.0, range.1);
}
Ok(Some(value))
}
pub fn validate_chat_template_args(
chat_template_args: Option<&std::collections::HashMap<String, serde_json::Value>>,
) -> Result<(), anyhow::Error> {
if let Some(args) = chat_template_args
&& args.contains_key("chat_template")
{
anyhow::bail!("`chat_template` is not supported inside `chat_template_args`");
}
Ok(())
}
pub fn validate_guided_decoding(request: &impl CommonExtProvider) -> Result<(), anyhow::Error> {
extract_guided_decoding_options(request)?;
Ok(())
}
pub fn validate_continue_final_message(
add_generation_prompt: Option<bool>,
continue_final_message: Option<bool>,
) -> Result<(), anyhow::Error> {
if continue_final_message != Some(true) {
return Ok(());
}
if add_generation_prompt.unwrap_or(true) {
anyhow::bail!(
"Cannot set both `continue_final_message` and `add_generation_prompt` to True."
);
}
Ok(())
}
pub fn validate_chat_only_generation_flags(
add_generation_prompt: Option<bool>,
continue_final_message: Option<bool>,
) -> Result<(), anyhow::Error> {
if add_generation_prompt.is_some() || continue_final_message.is_some() {
anyhow::bail!(
"`add_generation_prompt` and `continue_final_message` are only supported on /v1/chat/completions"
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use serde_json::json;
use super::*;
#[test]
fn test_validate_logit_bias_keys() {
let bias = |key: &str| Some(HashMap::from([(key.to_string(), json!(1.0))]));
let oversized_key = "x".repeat(16 * 1024);
for key in ["not-a-token", "-1", "", "1.5", "4294967296", &oversized_key] {
let msg = validate_logit_bias(&bias(key)).unwrap_err().to_string();
assert_eq!(
msg,
"Logit bias key must be a non-negative 32-bit integer token id"
);
}
for key in ["0", "50256", "4294967295"] {
assert!(validate_logit_bias(&bias(key)).is_ok(), "key: {key}");
}
}
#[test]
fn test_validate_logit_bias_values_with_valid_key() {
for (value, expected_error) in [
(json!("not-a-number"), "must be a number"),
(json!(-101.0), "must be between -100 and 100"),
(json!(101.0), "must be between -100 and 100"),
] {
let bias = Some(HashMap::from([("50256".to_string(), value)]));
let msg = validate_logit_bias(&bias).unwrap_err().to_string();
assert!(msg.contains("'50256'"), "unexpected error: {msg}");
assert!(msg.contains(expected_error), "unexpected error: {msg}");
}
}
fn unknown_fields() -> HashMap<String, serde_json::Value> {
HashMap::from([("experimental_field".to_string(), json!("value"))])
}
fn explicit_guided_decoding_options() -> Vec<CommonExt> {
vec![
CommonExt {
guided_json: Some(json!({"type": "object"})),
..Default::default()
},
CommonExt {
guided_regex: Some("[a-z]+".to_string()),
..Default::default()
},
CommonExt {
guided_choice: Some(vec!["yes".to_string(), "no".to_string()]),
..Default::default()
},
CommonExt {
guided_grammar: Some("root ::= 'yes'".to_string()),
..Default::default()
},
]
}
#[test]
fn structured_response_format_rejects_explicit_guided_decoding() {
let response_format = Some(dynamo_protocols::types::ResponseFormat::JsonObject);
for common in explicit_guided_decoding_options() {
let error = validate_response_format_conflicts(&response_format, &common).unwrap_err();
assert_eq!(
error.to_string(),
"`response_format` cannot be used together with `guided_json`, `guided_regex`, \
`guided_choice`, or `guided_grammar`"
);
}
}
#[test]
fn text_response_format_allows_explicit_guided_decoding() {
let response_format = Some(dynamo_protocols::types::ResponseFormat::Text);
for common in explicit_guided_decoding_options() {
validate_response_format_conflicts(&response_format, &common).unwrap();
}
}
#[test]
fn empty_guided_choice_does_not_conflict_with_response_format() {
let response_format = Some(dynamo_protocols::types::ResponseFormat::JsonObject);
let common = CommonExt {
guided_choice: Some(Vec::new()),
..Default::default()
};
validate_response_format_conflicts(&response_format, &common).unwrap();
}
#[test]
fn validate_chat_template_args_rejects_nested_chat_template() {
let args = HashMap::from([(
"chat_template".to_string(),
json!("{% for _ in range(10**9) %}x{% endfor %}"),
)]);
let err = validate_chat_template_args(Some(&args)).unwrap_err();
assert!(err.to_string().contains("chat_template"));
}
#[test]
fn validate_chat_template_args_accepts_other_keys() {
let args = HashMap::from([("enable_thinking".to_string(), json!(false))]);
validate_chat_template_args(Some(&args)).unwrap();
validate_chat_template_args(None).unwrap();
}
#[test]
fn validate_response_format_rejects_null_json_schema() {
let response_format = serde_json::from_value(json!({
"type": "json_schema",
"json_schema": {
"name": "test_schema",
"schema": null
}
}))
.unwrap();
let err = validate_response_format(&Some(response_format)).unwrap_err();
assert!(err.to_string().contains("schema` is required"));
}
#[test]
fn null_passthrough_fields_are_omitted_from_chat_and_completion_requests() {
use crate::engines::ValidateRequest;
use crate::protocols::common::extensions::request_cache_salt;
use crate::protocols::openai::{
OpenAIStopConditionsProvider, chat_completions::NvCreateChatCompletionRequest,
completions::NvCreateCompletionRequest,
};
let mut payload = json!({"model": "test-model"});
for field in PASSTHROUGH_EXTRA_FIELDS {
payload[field] = serde_json::Value::Null;
}
let mut chat_payload = payload.clone();
chat_payload["messages"] = json!([{"role": "user", "content": "hello"}]);
let chat: NvCreateChatCompletionRequest = serde_json::from_value(chat_payload).unwrap();
ValidateRequest::validate(&chat).unwrap();
assert!(chat.unsupported_fields.is_empty());
assert_eq!(chat.get_stop_token_ids(), None);
assert_eq!(request_cache_salt(&chat), None);
payload["prompt"] = json!("hello");
let completion: NvCreateCompletionRequest = serde_json::from_value(payload).unwrap();
ValidateRequest::validate(&completion).unwrap();
assert!(completion.unsupported_fields.is_empty());
assert_eq!(completion.get_stop_token_ids(), None);
assert_eq!(request_cache_salt(&completion), None);
let unknown_null: NvCreateChatCompletionRequest = serde_json::from_value(json!({
"model": "test-model",
"messages": [{"role": "user", "content": "hello"}],
"experimental_field": null,
}))
.unwrap();
assert_eq!(
unknown_null.unsupported_fields.get("experimental_field"),
Some(&serde_json::Value::Null)
);
let err = ValidateRequest::validate(&unknown_null).unwrap_err();
assert!(err.to_string().contains("experimental_field"));
}
#[test]
fn extra_field_deserialization_preserves_non_null_values() {
#[derive(serde::Deserialize)]
struct ExtraFields {
#[serde(flatten, deserialize_with = "deserialize_extra_fields")]
fields: HashMap<String, serde_json::Value>,
}
let fields: ExtraFields = serde_json::from_value(json!({
"cache_salt": "tenant",
"stop_token_ids": [],
"detokenize": false,
"allowed_token_ids": [1],
"bad_words_token_ids": [[2]],
"logprob_token_ids": [3],
}))
.unwrap();
validate_no_unsupported_fields_with_ignore(&fields.fields, false).unwrap();
assert_eq!(fields.fields.len(), PASSTHROUGH_EXTRA_FIELDS.len());
assert_eq!(fields.fields["detokenize"], json!(false));
assert_eq!(fields.fields["stop_token_ids"], json!([]));
for (field, bad) in [
("cache_salt", json!(7)),
("stop_token_ids", json!([null])),
("detokenize", json!(0)),
("allowed_token_ids", json!([-1])),
("bad_words_token_ids", json!([1])),
("logprob_token_ids", json!(["1"])),
("experimental_field", json!({"nested": null})),
] {
let fields: ExtraFields = serde_json::from_value(json!({field: bad})).unwrap();
assert_eq!(fields.fields[field], bad);
let err =
validate_no_unsupported_fields_with_ignore(&fields.fields, false).unwrap_err();
assert!(err.to_string().contains(field), "{err}");
}
}
#[test]
fn validate_no_unsupported_fields_accepts_logprob_token_ids() {
let fields = HashMap::from([("logprob_token_ids".to_string(), json!([14, 15]))]);
validate_no_unsupported_fields_with_ignore(&fields, false).unwrap();
}
#[test]
fn validate_no_unsupported_fields_rejects_malformed_logprob_token_ids() {
for bad in [json!(["notanint"]), json!(7), json!([[1, 2]]), json!([-1])] {
let fields = HashMap::from([("logprob_token_ids".to_string(), bad)]);
let err = validate_no_unsupported_fields_with_ignore(&fields, false).unwrap_err();
assert!(err.to_string().contains("must be an array of token IDs"));
}
}
#[test]
fn validate_no_unsupported_fields_rejects_unknown_fields_by_default() {
let err = validate_no_unsupported_fields_with_ignore(&unknown_fields(), false).unwrap_err();
assert!(err.to_string().contains("Unsupported parameter(s)"));
}
#[test]
fn validate_no_unsupported_fields_ignores_unknown_fields_when_configured() {
validate_no_unsupported_fields_with_ignore(&unknown_fields(), true).unwrap();
}
#[test]
fn validate_no_unsupported_fields_still_validates_passthrough_fields_when_ignoring_unknowns() {
let unsupported_fields = HashMap::from([
("experimental_field".to_string(), json!("value")),
("stop_token_ids".to_string(), json!("bad")),
]);
let err =
validate_no_unsupported_fields_with_ignore(&unsupported_fields, true).unwrap_err();
assert!(err.to_string().contains("stop_token_ids"));
}
#[test]
fn validate_top_p_rejects_zero() {
let err = validate_top_p(Some(0.0)).unwrap_err();
assert!(err.to_string().contains("Top_p"));
}
#[test]
fn validate_top_p_accepts_valid_values() {
validate_top_p(Some(0.1)).unwrap();
validate_top_p(Some(1.0)).unwrap();
validate_top_p(None).unwrap();
}
#[test]
fn validate_response_format_rejects_non_object_schema() {
let fmt = serde_json::from_value(json!({
"type": "json_schema",
"json_schema": { "name": "test", "schema": 42 }
}))
.unwrap();
let err = validate_response_format(&Some(fmt)).unwrap_err();
assert!(err.to_string().contains("must be a JSON object"));
}
#[test]
fn validate_response_format_accepts_valid_object_schema() {
let fmt = serde_json::from_value(json!({
"type": "json_schema",
"json_schema": {
"name": "test",
"schema": { "type": "object", "properties": {} }
}
}))
.unwrap();
validate_response_format(&Some(fmt)).unwrap();
}
#[test]
fn validate_stop_accepts_up_to_max_sequences() {
let max_strings = Some(dynamo_protocols::types::Stop::StringArray(
(0..MAX_STOP_SEQUENCES).map(|i| i.to_string()).collect(),
));
validate_stop(&max_strings).unwrap();
let max_token_ids = Some(dynamo_protocols::types::Stop::TokenIdArray(
(0..MAX_STOP_SEQUENCES as u32).collect(),
));
validate_stop(&max_token_ids).unwrap();
}
#[test]
fn validate_stop_rejects_over_max_sequences() {
let over_max_strings = Some(dynamo_protocols::types::Stop::StringArray(
(0..=MAX_STOP_SEQUENCES).map(|i| i.to_string()).collect(),
));
let err = validate_stop(&over_max_strings).unwrap_err();
assert_eq!(
err.to_string(),
format!(
"InvalidRequest: Maximum of {} stop sequences allowed, got {}",
MAX_STOP_SEQUENCES,
MAX_STOP_SEQUENCES + 1
)
);
let over_max_token_ids = Some(dynamo_protocols::types::Stop::TokenIdArray(
(0..=MAX_STOP_SEQUENCES as u32).collect(),
));
let err = validate_stop(&over_max_token_ids).unwrap_err();
assert_eq!(
err.to_string(),
format!(
"InvalidRequest: Maximum of {} stop token IDs allowed, got {}",
MAX_STOP_SEQUENCES,
MAX_STOP_SEQUENCES + 1
)
);
}
#[test]
fn validate_no_unsupported_fields_rejects_over_max_stop_token_ids() {
let over_max: Vec<u32> = (0..=MAX_STOP_SEQUENCES as u32).collect();
let unsupported_fields = HashMap::from([("stop_token_ids".to_string(), json!(over_max))]);
let err = validate_no_unsupported_fields(&unsupported_fields).unwrap_err();
assert_eq!(
err.to_string(),
format!(
"InvalidRequest: Maximum of {} stop token IDs allowed, got {}",
MAX_STOP_SEQUENCES,
MAX_STOP_SEQUENCES + 1
)
);
let at_max: Vec<u32> = (0..MAX_STOP_SEQUENCES as u32).collect();
let ok_fields = HashMap::from([("stop_token_ids".to_string(), json!(at_max))]);
validate_no_unsupported_fields(&ok_fields).unwrap();
}
}