Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -174,8 +174,10 @@ commitment.

Chat completion and completion requests currently capture extra top-level fields in an internal
map named `unsupported_fields`. The name describes the capture mechanism, not the final admission
decision. A small named set is accepted and validated for downstream handling. Other fields are
rejected by default. When `DYN_IGNORE_OPENAI_FE_UNSUPPORTED_FIELDS` is truthy, those other fields
decision. A small named set is accepted and validated for downstream handling. Chat completions
also accepts OpenAI's `safety_identifier`, `prompt_cache_retention`, and `verbosity`, validates
their values, and drops them, because Dynamo does not implement OpenAI's moderation, prompt cache
retention, or verbosity behavior. Other fields are rejected by default. When `DYN_IGNORE_OPENAI_FE_UNSUPPORTED_FIELDS` is truthy, those other fields
are ignored and dropped; the switch does not enable general forwarding. See the
[frontend configuration reference](../../../../reference/components/frontend-configuration.mdx)
for the exact switch behavior.
Expand Down
2 changes: 1 addition & 1 deletion lib/llm/src/protocols/openai/chat_completions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -709,7 +709,7 @@ impl OpenAIOutputOptionsProvider for NvCreateChatCompletionRequest {
impl ValidateRequest for NvCreateChatCompletionRequest {
fn validate(&self) -> Result<(), anyhow::Error> {
validate::validate_no_custom_tools(&self.inner)?;
validate::validate_no_unsupported_fields(&self.unsupported_fields)?;
validate::validate_no_unsupported_chat_fields(&self.unsupported_fields)?;
validate::validate_guided_decoding(self)?;
validate::validate_chat_template_args(self.chat_template_args.as_ref())?;
validate::validate_messages(&self.inner.messages)?;
Expand Down
119 changes: 109 additions & 10 deletions lib/llm/src/protocols/openai/validate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,15 @@ pub const PASSTHROUGH_EXTRA_FIELDS: &[&str] = &[
"vllm_xargs",
];

/// OpenAI Chat Completions fields that are accepted and dropped. They configure
/// OpenAI's moderation, prompt cache retention, and response verbosity, which
/// Dynamo does not implement.
pub const CHAT_IGNORED_FIELDS: &[&str] =
&["prompt_cache_retention", "safety_identifier", "verbosity"];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The OpenAI spec now deprecates prompt_cache_retention in favor of prompt_cache_options on the chat request, and openai-python 3.24+ exposes it. After this PR chat accepts the old field but still returns 400 for the new one. Add it here too?


/// Maximum `safety_identifier` length in the OpenAI request contract.
const MAX_SAFETY_IDENTIFIER_LENGTH: usize = 128;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Chat Completions contract limits safety_identifier to 64 characters, while 128 is the limit used by the Responses API. With this constant, Dynamo accepts and drops 65–128-character identifiers that OpenAI Chat Completions rejects, so the new compatibility validation does not match the endpoint contract. Use a 64-character limit for chat requests and cover the 64/65 boundary.

🤖 AI Fix

Change the Chat Completions safety identifier limit to 64 characters and add boundary coverage for lengths 64 and 65.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

128 is right for chat. The current OpenAI spec puts safety_identifier on ModelResponseProperties with maxLength: 128, and the chat request inherits it (spec). Older openai-python docstrings said 64 for both chat and Responses, so the 64/128 split never held.

Follow-up (later PR)


/// `vllm_xargs` keys vLLM itself fills from dedicated request fields. Dynamo only
/// forwards router-generated `kv_transfer_params`, so clients may not set them.
const RESERVED_VLLM_XARGS_KEYS: &[&str] = &["kv_transfer_params", "ec_transfer_params"];
Expand Down Expand Up @@ -158,17 +167,35 @@ pub fn validate_no_unsupported_fields(
) -> Result<(), anyhow::Error> {
validate_no_unsupported_fields_with_ignore(
unsupported_fields,
&[],
*IGNORE_OPENAI_FE_UNSUPPORTED_FIELDS,
)
}

/// Chat Completions version of [`validate_no_unsupported_fields`] that also
/// accepts [`CHAT_IGNORED_FIELDS`] after checking their values.
pub fn validate_no_unsupported_chat_fields(
unsupported_fields: &std::collections::HashMap<String, serde_json::Value>,
) -> Result<(), anyhow::Error> {
validate_no_unsupported_fields_with_ignore(
unsupported_fields,
CHAT_IGNORED_FIELDS,
*IGNORE_OPENAI_FE_UNSUPPORTED_FIELDS,
)?;
validate_chat_ignored_fields(unsupported_fields)
}

fn validate_no_unsupported_fields_with_ignore(
unsupported_fields: &std::collections::HashMap<String, serde_json::Value>,
accepted_fields: &[&str],
ignore_unsupported_fields: bool,
) -> Result<(), anyhow::Error> {
let unknown: Vec<_> = unsupported_fields
.keys()
.filter(|k| !PASSTHROUGH_EXTRA_FIELDS.contains(&k.as_str()))
.filter(|k| {
!PASSTHROUGH_EXTRA_FIELDS.contains(&k.as_str())
&& !accepted_fields.contains(&k.as_str())
})
.map(|s| format!("`{}`", s))
.collect();
if !unknown.is_empty() && !ignore_unsupported_fields {
Expand Down Expand Up @@ -214,6 +241,34 @@ fn validate_no_unsupported_fields_with_ignore(
Ok(())
}

/// Checks [`CHAT_IGNORED_FIELDS`] values against the OpenAI request contract.
fn validate_chat_ignored_fields(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up idea: /v1/responses keeps these as typed fields with different checks, so a 129-char safety_identifier gets 200 there and 400 here. One shared typed rule would touch many struct literals, so maybe a separate PR? Happy to leave this as-is here.

fields: &std::collections::HashMap<String, serde_json::Value>,
) -> Result<(), anyhow::Error> {
if let Some(value) = fields.get("safety_identifier")
&& !value.is_null()
&& value
.as_str()
.is_none_or(|id| id.chars().count() > MAX_SAFETY_IDENTIFIER_LENGTH)
{
anyhow::bail!(
"`safety_identifier` must be a string of at most {MAX_SAFETY_IDENTIFIER_LENGTH} characters"
);
}
for (field, allowed) in [
("prompt_cache_retention", &["in_memory", "24h"][..]),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

openai-python 2.8 to 2.32 types this field as Literal["in-memory", "24h"] (2.33 switched to in_memory). Those clients get a 400 here, and with DYN_IGNORE_OPENAI_FE_UNSUPPORTED_FIELDS set they go from 200 to 400. Should chat accept in-memory as an alias?

("verbosity", &["low", "medium", "high"][..]),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

verbosity changes the answer the caller gets, unlike the cache and safety hints. Is accept-and-drop the intended policy here, matching text.verbosity on /v1/responses, or should values other than medium get a 400?

] {
if let Some(value) = fields.get(field)
&& !value.is_null()
&& !value.as_str().is_some_and(|v| allowed.contains(&v))
{
anyhow::bail!("`{field}` must be one of: {}", allowed.join(", "));
}
}
Ok(())
}

/// Validates `vllm_xargs` against vLLM's OpenAI schema: an object whose values are
/// strings, numbers or booleans, or arrays of those.
fn validate_vllm_xargs(value: &serde_json::Value) -> Result<(), anyhow::Error> {
Expand Down Expand Up @@ -1379,7 +1434,7 @@ mod tests {
"vllm_xargs": {"flag": true},
}))
.unwrap();
validate_no_unsupported_fields_with_ignore(&fields.fields, false).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!([]));
Expand All @@ -1397,22 +1452,22 @@ mod tests {
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();
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();
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();
let err = validate_no_unsupported_fields_with_ignore(&fields, &[], false).unwrap_err();
assert!(err.to_string().contains("must be an array of token IDs"));
}
}
Expand All @@ -1436,7 +1491,7 @@ mod tests {
"tag": "read",
}),
)]);
validate_no_unsupported_fields_with_ignore(&fields, false).unwrap();
validate_no_unsupported_fields_with_ignore(&fields, &[], false).unwrap();
}

#[test]
Expand All @@ -1457,20 +1512,21 @@ mod tests {
),
] {
let fields = HashMap::from([("vllm_xargs".to_string(), bad)]);
let err = validate_no_unsupported_fields_with_ignore(&fields, false).unwrap_err();
let err = validate_no_unsupported_fields_with_ignore(&fields, &[], false).unwrap_err();
assert!(err.to_string().contains(message), "{err}");
}
}

#[test]
fn validate_no_unsupported_fields_rejects_unknown_fields_by_default() {
let err = validate_no_unsupported_fields_with_ignore(&unknown_fields(), false).unwrap_err();
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();
validate_no_unsupported_fields_with_ignore(&unknown_fields(), &[], true).unwrap();
}

#[test]
Expand All @@ -1481,10 +1537,53 @@ mod tests {
]);

let err =
validate_no_unsupported_fields_with_ignore(&unsupported_fields, true).unwrap_err();
validate_no_unsupported_fields_with_ignore(&unsupported_fields, &[], true).unwrap_err();
assert!(err.to_string().contains("stop_token_ids"));
}

#[test]
fn chat_ignored_fields_are_accepted_only_by_chat() {
let fields = HashMap::from([
(
"safety_identifier".to_string(),
json!("é".repeat(MAX_SAFETY_IDENTIFIER_LENGTH)),
),
("prompt_cache_retention".to_string(), json!("24h")),
("verbosity".to_string(), json!("low")),
]);
validate_no_unsupported_fields_with_ignore(&fields, CHAT_IGNORED_FIELDS, false).unwrap();
validate_chat_ignored_fields(&fields).unwrap();
let err = validate_no_unsupported_fields_with_ignore(&fields, &[], false).unwrap_err();
assert!(
err.to_string().contains("Unsupported parameter(s)"),
"{err}"
);

let nulls = CHAT_IGNORED_FIELDS
.iter()
.map(|field| (field.to_string(), serde_json::Value::Null))
.collect::<HashMap<_, _>>();
validate_chat_ignored_fields(&nulls).unwrap();
}

#[test]
fn chat_ignored_fields_reject_malformed_values() {
for (field, bad) in [
("safety_identifier", json!(7)),
(
"safety_identifier",
json!("x".repeat(MAX_SAFETY_IDENTIFIER_LENGTH + 1)),
),
("prompt_cache_retention", json!("1h")),
("verbosity", json!("loud")),
("verbosity", json!(["low"])),
] {
let fields = HashMap::from([(field.to_string(), bad)]);
let err = validate_chat_ignored_fields(&fields).unwrap_err();
assert!(err.to_string().contains(field), "{err}");
}
}

#[test]
fn validate_top_p_rejects_zero() {
let err = validate_top_p(Some(0.0)).unwrap_err();
Expand Down
55 changes: 55 additions & 0 deletions lib/llm/tests/frontend_protocol_validation_http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1220,6 +1220,61 @@ async fn responses_auto_tool_choice_without_tools_reaches_backend() {
.await;
}

/// OpenAI's `safety_identifier`, `prompt_cache_retention`, and `verbosity` reach the

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment only narrates what the test name and assertions already show: chat requests succeed without forwarding these fields, while legacy completions reject them. It preserves no additional constraint and should be removed.

🤖 AI Fix

Remove this redundant test doc comment.

/// backend on chat completions without being forwarded. Legacy completions, whose
/// contract has no such fields, still rejects them.
#[tokio::test]
#[serial]
async fn chat_completions_accept_and_drop_openai_ignored_fields() {
const FIELDS: [(&str, &str); 3] = [
("safety_identifier", "user-1234"),
("prompt_cache_retention", "24h"),
("verbosity", "low"),
];
fn with_fields(mut body: Value) -> Value {
for (field, value) in FIELDS {
body[field] = json!(value);
}
body
}

temp_env::async_with_vars(BASE_ENV, async {
let script = load_agent_fixture("text.sse").await.unwrap();
let svc = HarnessService::start(vec![script; 2]).await;

for stream in [false, true] {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The stream: true iteration is redundant with the retained stream: false case for this test's acceptance-and-drop contract. Both deserialize the same NvCreateChatCompletionRequest, run the same ValidateEngine before dispatch, and assert the identical serialized backend request; streaming diverges only while formatting the response, which this test does not inspect. One unary request still protects chat acceptance, omission from the backend request, and the separate legacy-completions rejection while avoiding a second scripted HTTP inference.

🤖 AI Fix

Replace the stream loop with one stream: false chat request and reduce the harness fixture to one backend script.

let body = with_fields(json!({
"model": MODEL,
"stream": stream,
"messages": [{"role": "user", "content": "ping"}],
}));
let response = post_json(&svc, "/v1/chat/completions", body).await;
assert_eq!(response.status(), reqwest::StatusCode::OK);
bounded(response.bytes()).await.unwrap();

let requests = svc.engine.take_requests().await;
assert_eq!(requests.len(), 1);
let forwarded = serde_json::to_value(&requests[0]).unwrap();
for (field, _) in FIELDS {
assert!(forwarded.get(field).is_none(), "{field} was forwarded");
}
}

let body = with_fields(json!({"model": MODEL, "prompt": "ping"}));
let response = post_json(&svc, "/v1/completions", body).await;
assert_openai_error(
response,
ExpectedError::Validation,
"Unsupported parameter(s)",
)
.await;

assert_eq!(svc.engine.remaining_scripts().await, 0);
svc.shutdown().await;
})
.await;
}

#[tokio::test]
#[serial]
async fn responses_reject_empty_input_and_required_tool_choice_without_tools() {
Expand Down
Loading