Skip to content
57 changes: 57 additions & 0 deletions lib/llm/src/http/service/openai.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3029,6 +3029,11 @@ async fn responses(
return Ok(resp.into_response());
}

if let Err(err_response) = validate_response_chat_template_args(&request) {
inflight_guard.mark_error(extract_error_type_from_response(&err_response));
return Err(err_response);
}

// Extract request parameters before into_parts() consumes the request.
// These are echoed back in the Response object per the OpenAI spec.
let response_params = ResponseParams {
Expand Down Expand Up @@ -3342,6 +3347,27 @@ pub fn validate_response_unsupported_fields(
None
}

/// Applies the chat-template-argument guard to a Responses request.
///
/// `/v1/responses` converts to `NvCreateChatCompletionRequest` but never runs
/// `ValidateRequest` on the result, so the guard that `NvCreateChatCompletionRequest::validate`
/// applies to `chat_template_args` does not reach this path. Since the Responses request now
/// carries and forwards that field, call the same check directly and return the same 400 the
/// chat endpoint would, so an identical payload is rejected identically on both endpoints.
pub fn validate_response_chat_template_args(
request: &NvCreateResponse,
) -> Result<(), ErrorResponse> {
crate::protocols::openai::validate::validate_chat_template_args(
request.chat_template_args.as_ref(),
)
.map_err(|e| {
ErrorMessage::from_http_error(HttpError {
code: 400,
message: VALIDATION_PREFIX.to_string() + &e.to_string(),
})
})
}

// todo - abstract this to the top level lib.rs to be reused
pub(crate) fn check_ready(state: &Arc<service_v2::State>) -> Result<(), ErrorResponse> {
if !state.is_ready() {
Expand Down Expand Up @@ -4689,6 +4715,7 @@ mod tests {
..Default::default()
},
nvext: None,
chat_template_args: None,
}
}

Expand Down Expand Up @@ -5078,6 +5105,36 @@ mod tests {
assert!(validate_response_unsupported_fields(&request).is_some());
}

#[test]
fn test_validate_response_chat_template_args_rejects_nested_chat_template() {
let mut request = make_base_request();
request.chat_template_args = Some(HashMap::from([(
"chat_template".to_string(),
serde_json::json!("{{ 'pwned' }}"),
)]));

let (status, Json(body)) = validate_response_chat_template_args(&request).unwrap_err();
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(
body.message,
format!(
"{VALIDATION_PREFIX}`chat_template` is not supported inside `chat_template_args`"
)
);
}

#[test]
fn test_validate_response_chat_template_args_accepts_ordinary_args() {
let mut request = make_base_request();
request.chat_template_args = Some(HashMap::from([(
"enable_thinking".to_string(),
serde_json::json!(true),
)]));

assert!(validate_response_chat_template_args(&request).is_ok());
assert!(validate_response_chat_template_args(&make_base_request()).is_ok());
}

#[test]
fn test_validate_unsupported_fields_accepts_supported_nvext_fields() {
let mut request = make_base_request();
Expand Down
105 changes: 104 additions & 1 deletion lib/llm/src/protocols/openai/responses/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,22 @@ pub struct NvCreateResponse {
#[serde(skip_serializing_if = "Option::is_none")]
#[schema(value_type = Object)]
pub nvext: Option<NvExt>,

/// Extra args to pass to the chat template rendering context.
/// Also accepts "chat_template_kwargs" as an alias for compatibility.
///
/// Declared here rather than inherited from the flattened `inner`:
/// `CreateResponse` has no equivalent field, and because `inner` is
/// flattened, an undeclared top-level key is silently swallowed rather
/// than rejected. Mirrors the identically named field on
/// `NvCreateChatCompletionRequest`, which is where
/// `TryFrom<NvCreateResponse>` forwards it.
#[serde(
default,
skip_serializing_if = "Option::is_none",
alias = "chat_template_kwargs"
)]
pub chat_template_args: Option<HashMap<String, serde_json::Value>>,
Comment thread
glamr-agent marked this conversation as resolved.
}

#[derive(ToSchema, Deserialize, Validate, Debug, Clone)]
Expand Down Expand Up @@ -803,7 +819,7 @@ impl TryFrom<NvCreateResponse> for NvCreateChatCompletionRequest {
},
common: Default::default(),
nvext: resp.nvext,
chat_template_args: None,
chat_template_args: resp.chat_template_args,
Comment thread
MatejKosec marked this conversation as resolved.
thinking: None,
media_io_kwargs: None,
return_tokens_as_token_ids: None,
Expand Down Expand Up @@ -1224,6 +1240,7 @@ mod tests {
annotations: Some(vec!["debug".into(), "trace".into()]),
..Default::default()
}),
chat_template_args: None,
}
}

Expand Down Expand Up @@ -1300,6 +1317,71 @@ mod tests {
assert_eq!(nv_req.inner.store, Some(true));
}

#[test]
fn test_responses_chat_template_args_forwarded_to_chat_completion() {
// Deserialize from the wire rather than building the struct by hand:
// the field only reaches the conversion if serde binds the top-level
// key past the flattened `inner`.
let req: NvCreateResponse = serde_json::from_value(serde_json::json!({
"model": "dummy-model",
"input": "hello",
"chat_template_args": { "enable_thinking": true }
}))
.unwrap();

let chat_req = NvCreateChatCompletionRequest::try_from(req).unwrap();

let args = chat_req
.chat_template_args
.expect("chat_template_args must survive the Responses to Chat conversion");
assert_eq!(args.get("enable_thinking"), Some(&serde_json::json!(true)));
}

#[test]
fn test_responses_chat_template_kwargs_alias_forwarded_to_chat_completion() {
// vLLM-style spelling; accepted on /v1/chat/completions, so a client
// should not have to respell it for /v1/responses.
let req: NvCreateResponse = serde_json::from_value(serde_json::json!({
"model": "dummy-model",
"input": "hello",
"chat_template_kwargs": { "enable_thinking": true }
}))
.unwrap();

let chat_req = NvCreateChatCompletionRequest::try_from(req).unwrap();

let args = chat_req
.chat_template_args
.expect("chat_template_kwargs alias must bind chat_template_args");
assert_eq!(args.get("enable_thinking"), Some(&serde_json::json!(true)));
}

#[test]
fn test_responses_without_chat_template_args_converts_to_none() {
// Not an empty map: an empty map would render a different prompt and
// would defeat `skip_serializing_if` on the chat request.
let req: NvCreateResponse = serde_json::from_value(serde_json::json!({
"model": "dummy-model",
"input": "hello"
}))
.unwrap();

let chat_req = NvCreateChatCompletionRequest::try_from(req).unwrap();

assert!(chat_req.chat_template_args.is_none());
}

#[test]
fn test_responses_chat_template_args_rejects_non_object() {
let err = serde_json::from_value::<NvCreateResponse>(serde_json::json!({
"model": "dummy-model",
"input": "hello",
"chat_template_args": "enable_thinking"
}));

assert!(err.is_err());
}

#[test]
fn test_instructions_prepended_as_system_message() {
let req = NvCreateResponse {
Expand All @@ -1310,6 +1392,7 @@ mod tests {
..Default::default()
},
nvext: None,
chat_template_args: None,
};

let chat_req: NvCreateChatCompletionRequest = req.try_into().unwrap();
Expand Down Expand Up @@ -1354,6 +1437,7 @@ mod tests {
..Default::default()
},
nvext: None,
chat_template_args: None,
};

let chat_req: NvCreateChatCompletionRequest = req.try_into().unwrap();
Expand Down Expand Up @@ -1433,6 +1517,7 @@ mod tests {
..Default::default()
},
nvext: None,
chat_template_args: None,
};

let chat_req: NvCreateChatCompletionRequest = req.try_into().unwrap();
Expand Down Expand Up @@ -1474,6 +1559,7 @@ mod tests {
..Default::default()
},
nvext: None,
chat_template_args: None,
};

let chat_req: NvCreateChatCompletionRequest = req.try_into().unwrap();
Expand Down Expand Up @@ -1517,6 +1603,7 @@ mod tests {
..Default::default()
},
nvext: None,
chat_template_args: None,
};

let chat_req: NvCreateChatCompletionRequest = req.try_into().unwrap();
Expand Down Expand Up @@ -1565,6 +1652,7 @@ mod tests {
..Default::default()
},
nvext: None,
chat_template_args: None,
};

let chat_req: NvCreateChatCompletionRequest = req.try_into().unwrap();
Expand Down Expand Up @@ -1604,6 +1692,7 @@ mod tests {
..Default::default()
},
nvext: None,
chat_template_args: None,
};

let chat_req: NvCreateChatCompletionRequest = req.try_into().unwrap();
Expand Down Expand Up @@ -1638,6 +1727,7 @@ mod tests {
..Default::default()
},
nvext: None,
chat_template_args: None,
};

let chat_req: NvCreateChatCompletionRequest = req.try_into().unwrap();
Expand Down Expand Up @@ -1680,6 +1770,7 @@ mod tests {
..Default::default()
},
nvext: None,
chat_template_args: None,
};

let chat_req: NvCreateChatCompletionRequest = req.try_into().unwrap();
Expand Down Expand Up @@ -1741,6 +1832,7 @@ mod tests {
..Default::default()
},
nvext: None,
chat_template_args: None,
};

let chat_req: NvCreateChatCompletionRequest = req.try_into().unwrap();
Expand Down Expand Up @@ -1813,6 +1905,7 @@ mod tests {
..Default::default()
},
nvext: None,
chat_template_args: None,
};

let chat_req: NvCreateChatCompletionRequest = req.try_into().unwrap();
Expand Down Expand Up @@ -1870,6 +1963,7 @@ mod tests {
..Default::default()
},
nvext: None,
chat_template_args: None,
};

let chat_req: NvCreateChatCompletionRequest = req.try_into().unwrap();
Expand Down Expand Up @@ -1925,6 +2019,7 @@ mod tests {
..Default::default()
},
nvext: None,
chat_template_args: None,
};

let chat_req: NvCreateChatCompletionRequest = req.try_into().unwrap();
Expand Down Expand Up @@ -1972,6 +2067,7 @@ mod tests {
..Default::default()
},
nvext: None,
chat_template_args: None,
};

let chat_req: NvCreateChatCompletionRequest = req.try_into().unwrap();
Expand Down Expand Up @@ -2037,6 +2133,7 @@ mod tests {
..Default::default()
},
nvext: None,
chat_template_args: None,
};

let chat_req: NvCreateChatCompletionRequest = req.try_into().unwrap();
Expand Down Expand Up @@ -2116,6 +2213,7 @@ mod tests {
..Default::default()
},
nvext: None,
chat_template_args: None,
};

let chat_req: NvCreateChatCompletionRequest = req.try_into().unwrap();
Expand Down Expand Up @@ -2189,6 +2287,7 @@ mod tests {
..Default::default()
},
nvext: None,
chat_template_args: None,
};
let chat_req: NvCreateChatCompletionRequest = req.try_into().unwrap();
let messages = &chat_req.inner.messages;
Expand Down Expand Up @@ -2258,6 +2357,7 @@ mod tests {
..Default::default()
},
nvext: None,
chat_template_args: None,
};
let chat_req: NvCreateChatCompletionRequest = req.try_into().unwrap();
let messages = &chat_req.inner.messages;
Expand Down Expand Up @@ -2324,6 +2424,7 @@ mod tests {
..Default::default()
},
nvext: None,
chat_template_args: None,
};
let chat_req: NvCreateChatCompletionRequest = req.try_into().unwrap();
let messages = &chat_req.inner.messages;
Expand Down Expand Up @@ -2381,6 +2482,7 @@ mod tests {
..Default::default()
},
nvext: None,
chat_template_args: None,
};
let chat_req: NvCreateChatCompletionRequest = req.try_into().unwrap();
let messages = &chat_req.inner.messages;
Expand Down Expand Up @@ -2420,6 +2522,7 @@ mod tests {
..Default::default()
},
nvext: None,
chat_template_args: None,
};

let chat_req: NvCreateChatCompletionRequest = req.try_into().unwrap();
Expand Down
Loading