Skip to content
86 changes: 71 additions & 15 deletions lib/llm/src/http/service/openai.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3244,6 +3244,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);
}

// Validate sampling and output parameters
if let Err(err_response) = validate_responses_fields(&request) {
inflight_guard.mark_error(extract_error_type_from_response(&err_response));
Expand Down Expand Up @@ -3344,28 +3349,27 @@ async fn responses(
),
);

// Any force_nonempty_content=true request: surface reasoning as content when
// the turn produced none, mirroring the chat_completions handler above. A
// non-streaming Responses request reaches the aggregator (forcing
// stream=true on the converted request only drives internal streaming; the
// client-facing `streaming` flag still selects the aggregating branch
// below), so this flag has to be set here too, from the converted chat
// request's own chat_template_args.
let move_reasoning_to_content_when_empty =
crate::preprocessor::OpenAIPreprocessor::wants_reasoning_as_content_when_empty(
request.chat_template_args.as_ref(),
);
let parsing_options = parsing_options
.with_move_reasoning_to_content_when_empty(move_reasoning_to_content_when_empty);

// Responses requests share the chat-completions aggregator for the unary
// path. Thread this option through so its post-parse fallback also caps a
// model-produced batch to the first tool call when parallel calls are
// disabled. The streaming Responses converter enforces the same contract.
let parsing_options =
parsing_options.with_parallel_tool_calls(request.inner.parallel_tool_calls);

// NOTE: `move_reasoning_to_content_when_empty` is the aggregator flag and is
// not set here. A non-streaming Responses request DOES reach the aggregator
// (forcing stream=true on the converted request only drives internal
// streaming; the client-facing `streaming` flag still selects the aggregating
// branch below), so it reaches it with the flag false.
//
// That is currently unreachable rather than wrong: the Responses-to-chat
// conversion hard-codes `chat_template_args: None`, so
// `force_nonempty_content` can never be set on this path in the first place.
// If that conversion ever forwards chat_template_args, the streaming stage
// would still cover the reasoning-only case — `postprocessor_parsing_stream`
// gates on the request's own args via `wants_reasoning_as_content_when_empty`
// rather than on this flag — but the aggregator backstop should be wired here
// too at that point. Tracked as follow-up.

let mut response_collector = state
.metrics_clone()
.create_response_collector(&metric_model);
Expand Down Expand Up @@ -3589,6 +3593,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(),
})
})
}

/// Validates sampling and output parameters on the Responses API request.
pub fn validate_responses_fields(request: &NvCreateResponse) -> Result<(), ErrorResponse> {
use crate::protocols::openai::validate;
Expand Down Expand Up @@ -4988,6 +5013,7 @@ mod tests {
..Default::default()
},
nvext: None,
chat_template_args: None,
}
}

Expand Down Expand Up @@ -5573,6 +5599,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
1 change: 1 addition & 0 deletions lib/llm/src/protocols/common/input_trigger.rs
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ mod tests {
..Default::default()
},
nvext: None,
chat_template_args: None,
}
}

Expand Down
Loading
Loading