pub mod stream_converter;
mod tool_names;
pub use tool_names::ToolNameMap;
use std::collections::{HashMap, HashSet};
use dynamo_protocols::types::responses::{
AssistantRole, FunctionCallOutput, FunctionToolCall, IncludeEnum, IncompleteDetails,
InputContent, InputImageContent, InputItem, InputOutputMessageContent, InputParam, InputRole,
InputTokenDetails, Instructions, Item, MessageItem, NamespaceToolParamTool, OutputItem,
OutputMessage, OutputMessageContent, OutputStatus, OutputTextContent, OutputTokenDetails,
PromptCacheRetention, Reasoning, ReasoningItem, ReasoningItemContent, ReasoningTextContent,
Response, ResponseTextParam, ResponseUsage, Role as ResponseRole, ServiceTierResponses, Status,
SummaryPart, TextResponseFormatConfiguration, Tool, ToolChoiceAllowed, ToolChoiceAllowedMode,
ToolChoiceOptions, ToolChoiceParam, Truncation,
};
use dynamo_protocols::types::{
ChatCompletionMessageToolCall, ChatCompletionNamedToolChoice,
ChatCompletionRequestAssistantMessage, ChatCompletionRequestAssistantMessageContent,
ChatCompletionRequestMessage, ChatCompletionRequestMessageContentPartImage,
ChatCompletionRequestMessageContentPartImageArgs, ChatCompletionRequestMessageContentPartText,
ChatCompletionRequestSystemMessage, ChatCompletionRequestSystemMessageContent,
ChatCompletionRequestToolMessage, ChatCompletionRequestToolMessageContent,
ChatCompletionRequestToolMessageContentPart, ChatCompletionRequestUserMessage,
ChatCompletionRequestUserMessageContent, ChatCompletionRequestUserMessageContentPart,
ChatCompletionTool, ChatCompletionToolChoiceOption, ChatCompletionToolType,
CreateChatCompletionRequest, FinishReason, FunctionName, FunctionObject, FunctionType,
ImageUrl, ReasoningContent, ReasoningEffort as ChatReasoningEffort, ResponseFormat,
ServiceTier as ChatServiceTier,
};
use dynamo_runtime::protocols::annotated::AnnotationsProvider;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
use validator::Validate;
use super::chat_completions::{NvCreateChatCompletionRequest, NvCreateChatCompletionResponse};
use super::{OpenAISamplingOptionsProvider, OpenAIStopConditionsProvider};
use crate::protocols::common::extensions::{NvExt, NvExtProvider};
#[derive(ToSchema, Serialize, Deserialize, Validate, Debug, Clone, Default)]
pub struct NvCreateResponse {
#[serde(flatten)]
#[schema(value_type = Object)]
pub inner: dynamo_protocols::types::responses::CreateResponse,
#[serde(skip_serializing_if = "Option::is_none")]
#[schema(value_type = Object)]
pub nvext: Option<NvExt>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
alias = "chat_template_kwargs"
)]
#[schema(value_type = Object)]
pub chat_template_args: Option<std::collections::HashMap<String, serde_json::Value>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub thinking_token_budget: Option<u32>,
}
#[derive(ToSchema, Deserialize, Validate, Debug, Clone)]
pub struct NvResponse {
#[serde(flatten)]
#[schema(value_type = Object)]
pub inner: dynamo_protocols::types::responses::Response,
#[serde(skip_serializing_if = "Option::is_none")]
pub nvext: Option<serde_json::Value>,
#[serde(default)]
pub presence_penalty: f32,
#[serde(default)]
pub frequency_penalty: f32,
#[serde(default)]
pub store: bool,
}
pub(crate) fn patch_response_for_spec(
obj: &mut serde_json::Map<String, serde_json::Value>,
presence_penalty: f32,
frequency_penalty: f32,
store: bool,
) {
for key in dynamo_protocols::types::responses::SPEC_NULLABLE_REQUIRED_RESPONSE_FIELDS {
obj.entry(*key).or_insert(serde_json::Value::Null);
}
obj.insert(
"presence_penalty".into(),
serde_json::json!(presence_penalty),
);
obj.insert(
"frequency_penalty".into(),
serde_json::json!(frequency_penalty),
);
obj.insert("store".into(), serde_json::json!(store));
}
impl Serialize for NvResponse {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let mut value = serde_json::to_value(&self.inner).map_err(serde::ser::Error::custom)?;
let serde_json::Value::Object(obj) = &mut value else {
return value.serialize(serializer);
};
patch_response_for_spec(
obj,
self.presence_penalty,
self.frequency_penalty,
self.store,
);
if let Some(nvext) = &self.nvext {
obj.insert("nvext".into(), nvext.clone());
}
value.serialize(serializer)
}
}
impl NvExtProvider for NvCreateResponse {
fn nvext(&self) -> Option<&NvExt> {
self.nvext.as_ref()
}
fn raw_prompt(&self) -> Option<String> {
None
}
}
impl AnnotationsProvider for NvCreateResponse {
fn annotations(&self) -> Option<Vec<String>> {
self.nvext
.as_ref()
.and_then(|nvext| nvext.annotations.clone())
}
fn has_annotation(&self, annotation: &str) -> bool {
self.nvext
.as_ref()
.and_then(|nvext| nvext.annotations.as_ref())
.map(|annotations| annotations.contains(&annotation.to_string()))
.unwrap_or(false)
}
}
impl OpenAISamplingOptionsProvider for NvCreateResponse {
fn get_temperature(&self) -> Option<f32> {
self.inner.temperature
}
fn get_top_p(&self) -> Option<f32> {
self.inner.top_p
}
fn get_frequency_penalty(&self) -> Option<f32> {
None
}
fn get_presence_penalty(&self) -> Option<f32> {
None
}
fn nvext(&self) -> Option<&NvExt> {
self.nvext.as_ref()
}
fn get_seed(&self) -> Option<i64> {
None
}
fn get_n(&self) -> Option<u8> {
None
}
fn get_best_of(&self) -> Option<u8> {
None
}
}
impl OpenAIStopConditionsProvider for NvCreateResponse {
#[allow(deprecated)]
fn get_max_tokens(&self) -> Option<u32> {
self.inner.max_output_tokens
}
fn get_min_tokens(&self) -> Option<u32> {
None
}
fn get_stop(&self) -> Option<Vec<String>> {
None
}
fn nvext(&self) -> Option<&NvExt> {
self.nvext.as_ref()
}
fn get_thinking_token_budget(&self) -> Option<u32> {
self.thinking_token_budget
}
}
#[derive(Debug, thiserror::Error)]
pub(crate) enum ResponsesConversionError {
#[error("{0}")]
InvalidArgument(String),
#[error("{0}")]
UnsupportedContent(String),
}
fn convert_input_image_to_chat_image(
img: &InputImageContent,
) -> Result<ChatCompletionRequestMessageContentPartImage, anyhow::Error> {
let url_str = match (img.file_id.as_deref(), img.image_url.as_deref()) {
(None, None) => {
return Err(ResponsesConversionError::InvalidArgument(
"input_image requires file_id or image_url".to_string(),
)
.into());
}
(Some(file_id), None) if file_id.trim().is_empty() => {
return Err(ResponsesConversionError::InvalidArgument(
"input_image file_id must be non-empty".to_string(),
)
.into());
}
(Some(_), None) => {
return Err(ResponsesConversionError::UnsupportedContent(
"Image input by file_id is not yet supported".to_string(),
)
.into());
}
(_, Some(url_str)) => url_str,
};
let url = url::Url::parse(url_str).map_err(|error| {
ResponsesConversionError::InvalidArgument(format!("Invalid image URL: {error}"))
})?;
let mut image_url = ImageUrl::from(url.to_string());
image_url.detail = Some(img.detail.clone());
Ok(ChatCompletionRequestMessageContentPartImageArgs::default()
.image_url(image_url)
.build()?)
}
fn convert_input_content_to_user_content(
content: &[InputContent],
) -> Result<ChatCompletionRequestUserMessageContent, anyhow::Error> {
if content.len() == 1
&& let InputContent::InputText(t) = &content[0]
{
return Ok(ChatCompletionRequestUserMessageContent::Text(
t.text.clone(),
));
}
let mut chat_parts = Vec::with_capacity(content.len());
for part in content {
match part {
InputContent::InputText(t) => {
chat_parts.push(ChatCompletionRequestUserMessageContentPart::Text(
ChatCompletionRequestMessageContentPartText {
text: t.text.clone(),
..Default::default()
},
));
}
InputContent::InputImage(img) => {
chat_parts.push(convert_input_image_to_chat_image(img)?.into());
}
InputContent::InputFile(file) => {
let (source_field, source_value) = match (
file.file_data.as_deref(),
file.file_id.as_deref(),
file.file_url.as_deref(),
) {
(Some(file_data), None, None) => ("file_data", file_data),
(None, Some(file_id), None) => ("file_id", file_id),
(None, None, Some(file_url)) => ("file_url", file_url),
_ => {
return Err(ResponsesConversionError::InvalidArgument(
"input_file requires exactly one of file_data, file_id, or file_url"
.to_string(),
)
.into());
}
};
if source_value.trim().is_empty() {
return Err(ResponsesConversionError::InvalidArgument(format!(
"input_file {source_field} must be non-empty"
))
.into());
}
if source_field == "file_url" {
url::Url::parse(source_value).map_err(|error| {
ResponsesConversionError::InvalidArgument(format!(
"Invalid file URL '{source_value}': {error}"
))
})?;
}
return Err(ResponsesConversionError::UnsupportedContent(
"File input content is not yet supported".to_string(),
)
.into());
}
}
}
Ok(ChatCompletionRequestUserMessageContent::Array(chat_parts))
}
fn convert_input_content_to_text(content: &[InputContent]) -> String {
content
.iter()
.filter_map(|p| match p {
InputContent::InputText(t) => Some(t.text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("")
}
fn convert_function_call_output_content(
content: &[InputContent],
) -> Result<ChatCompletionRequestToolMessageContent, anyhow::Error> {
if content
.iter()
.all(|part| matches!(part, InputContent::InputText(_)))
{
return Ok(ChatCompletionRequestToolMessageContent::Text(
convert_input_content_to_text(content),
));
}
let mut parts = Vec::with_capacity(content.len());
for part in content {
match part {
InputContent::InputText(text) => {
parts.push(ChatCompletionRequestToolMessageContentPart::Text(
ChatCompletionRequestMessageContentPartText {
text: text.text.clone(),
..Default::default()
},
));
}
InputContent::InputImage(img) => {
parts.push(ChatCompletionRequestToolMessageContentPart::ImageUrl(
convert_input_image_to_chat_image(img)?,
));
}
InputContent::InputFile(_) => {
return Err(ResponsesConversionError::UnsupportedContent(
"File function call output content is not yet supported".to_string(),
)
.into());
}
}
}
Ok(ChatCompletionRequestToolMessageContent::Array(parts))
}
#[derive(Default)]
struct PendingAssistant {
content: Option<String>,
reasoning_segments: Vec<String>,
pending_reasoning: String,
tool_calls: Vec<ChatCompletionMessageToolCall>,
touched: bool,
}
impl PendingAssistant {
fn push_text(&mut self, text: &str) {
self.touched = true;
if text.is_empty() {
return;
}
match self.content.as_mut() {
Some(existing) => existing.push_str(text),
None => self.content = Some(text.to_string()),
}
}
fn push_reasoning(&mut self, text: &str) {
self.touched = true;
self.pending_reasoning.push_str(text);
}
fn push_tool_call(&mut self, call: ChatCompletionMessageToolCall) {
self.touched = true;
self.reasoning_segments
.push(std::mem::take(&mut self.pending_reasoning));
self.tool_calls.push(call);
}
fn flush_into(mut self, out: &mut Vec<ChatCompletionRequestMessage>) {
if !self.touched {
return;
}
self.reasoning_segments.push(self.pending_reasoning);
let reasoning_content = if !self.tool_calls.is_empty()
&& self.reasoning_segments.iter().any(|text| !text.is_empty())
{
Some(ReasoningContent::Segments(self.reasoning_segments))
} else {
let text = self.reasoning_segments.concat();
(!text.is_empty()).then_some(ReasoningContent::Text(text))
};
let content = if self.content.is_some() || self.tool_calls.is_empty() {
Some(
self.content
.map(ChatCompletionRequestAssistantMessageContent::Text)
.unwrap_or_else(|| {
ChatCompletionRequestAssistantMessageContent::Text(String::new())
}),
)
} else {
None
};
out.push(ChatCompletionRequestMessage::Assistant(
ChatCompletionRequestAssistantMessage {
content,
reasoning_content,
refusal: None,
name: None,
audio: None,
tool_calls: if self.tool_calls.is_empty() {
None
} else {
Some(self.tool_calls)
},
#[allow(deprecated)]
function_call: None,
partial: None,
},
));
}
}
fn convert_input_items_to_messages(
items: &[InputItem],
names: &ToolNameMap,
) -> Result<Vec<ChatCompletionRequestMessage>, anyhow::Error> {
let mut messages = Vec::with_capacity(items.len());
let mut pending = PendingAssistant::default();
for item in items {
match item {
InputItem::Item(inner_item) => match inner_item {
Item::Message(msg_item) => match msg_item {
MessageItem::Input(msg) => {
std::mem::take(&mut pending).flush_into(&mut messages);
let chat_msg = match msg.role {
InputRole::System | InputRole::Developer => {
let text = convert_input_content_to_text(&msg.content);
ChatCompletionRequestMessage::System(
ChatCompletionRequestSystemMessage {
content: ChatCompletionRequestSystemMessageContent::Text(
text,
),
name: None,
tools: None,
},
)
}
InputRole::User => {
let content = convert_input_content_to_user_content(&msg.content)?;
ChatCompletionRequestMessage::User(
ChatCompletionRequestUserMessage {
content,
name: None,
},
)
}
};
messages.push(chat_msg);
}
MessageItem::Output(out_msg) => {
let text = out_msg
.content
.iter()
.map(|c| match c {
InputOutputMessageContent::OutputText(t) => t.text.as_str(),
InputOutputMessageContent::Refusal(r) => r.refusal.as_str(),
})
.collect::<Vec<_>>()
.join("");
pending.push_text(&text);
}
},
Item::FunctionCall(fc) => {
pending.push_tool_call(ChatCompletionMessageToolCall {
id: fc.call_id.clone(),
r#type: FunctionType::Function,
function: dynamo_protocols::types::FunctionCall {
name: names.encode(fc.namespace.as_deref(), &fc.name),
arguments: fc.arguments.clone(),
},
});
}
Item::FunctionCallOutput(fco) => {
std::mem::take(&mut pending).flush_into(&mut messages);
let content = match &fco.output {
FunctionCallOutput::Text(text) => text.clone().into(),
FunctionCallOutput::Content(parts) => {
convert_function_call_output_content(parts)?
}
};
messages.push(ChatCompletionRequestMessage::Tool(
ChatCompletionRequestToolMessage {
content,
tool_call_id: fco.call_id.clone(),
},
));
}
Item::Reasoning(r) => {
let content = r
.content
.as_ref()
.map(|parts| {
parts
.iter()
.map(|part| part.text.as_str())
.collect::<String>()
})
.filter(|text| !text.is_empty());
let summary = || {
r.summary
.iter()
.map(|SummaryPart::SummaryText(part)| part.text.as_str())
.collect::<String>()
};
let text = content.unwrap_or_else(summary);
pending.push_reasoning(&text);
}
other => {
tracing::debug!(
"Skipping unsupported input item type during conversion: {:?}",
std::mem::discriminant(other)
);
std::mem::take(&mut pending).flush_into(&mut messages);
}
},
InputItem::EasyMessage(easy) => {
use dynamo_protocols::types::responses::EasyInputContent;
match easy.role {
ResponseRole::System | ResponseRole::Developer => {
let text = match &easy.content {
EasyInputContent::Text(t) => t.clone(),
EasyInputContent::ContentList(parts) => {
convert_input_content_to_text(parts)
}
};
std::mem::take(&mut pending).flush_into(&mut messages);
messages.push(ChatCompletionRequestMessage::System(
ChatCompletionRequestSystemMessage {
content: ChatCompletionRequestSystemMessageContent::Text(text),
name: None,
tools: None,
},
));
}
ResponseRole::User => {
let content = match &easy.content {
EasyInputContent::Text(t) => {
ChatCompletionRequestUserMessageContent::Text(t.clone())
}
EasyInputContent::ContentList(parts) => {
convert_input_content_to_user_content(parts)?
}
};
std::mem::take(&mut pending).flush_into(&mut messages);
messages.push(ChatCompletionRequestMessage::User(
ChatCompletionRequestUserMessage {
content,
name: None,
},
));
}
ResponseRole::Assistant => {
let text = match &easy.content {
EasyInputContent::Text(t) => t.clone(),
EasyInputContent::ContentList(parts) => {
convert_input_content_to_text(parts)
}
};
pending.push_text(&text);
}
}
}
InputItem::ItemReference(_) => {
}
}
}
pending.flush_into(&mut messages);
Ok(messages)
}
fn convert_tools(tools: &[Tool], names: &ToolNameMap) -> anyhow::Result<Vec<ChatCompletionTool>> {
let mut converted = Vec::new();
let mut push_function = |name: &str,
description: &Option<String>,
parameters: &Option<serde_json::Value>,
strict: Option<bool>,
namespace: Option<&str>| {
converted.push(ChatCompletionTool {
r#type: ChatCompletionToolType::Function,
function: FunctionObject {
name: names.encode(namespace, name),
description: description.clone(),
parameters: parameters.clone(),
strict,
},
});
};
for tool in tools {
match tool {
Tool::Function(f) => {
push_function(&f.name, &f.description, &f.parameters, f.strict, None)
}
Tool::Namespace(namespace) => {
for tool in &namespace.tools {
match tool {
NamespaceToolParamTool::Function(f) => push_function(
&f.name,
&f.description,
&f.parameters,
f.strict,
Some(&namespace.name),
),
_ => return unsupported_tool(tool, "tools"),
}
}
}
_ => return unsupported_tool(tool, "tools"),
}
}
Ok(converted)
}
fn unsupported_tool<T>(tool: &impl serde::Serialize, field: &str) -> anyhow::Result<T> {
let value = serde_json::to_value(tool)?;
let tool_type = value["type"].as_str().unwrap_or("unknown");
Err(ResponsesConversionError::InvalidArgument(format!(
"Unsupported Responses {field} type '{tool_type}': the Chat Completions adapter supports only function tools and none, auto, required, named function, or function-only allowed_tools choices"
))
.into())
}
fn convert_tool_choice(
tc: &ToolChoiceParam,
names: &ToolNameMap,
) -> anyhow::Result<ChatCompletionToolChoiceOption> {
Ok(match tc {
ToolChoiceParam::Option(mode) => match mode {
ToolChoiceOptions::None => ChatCompletionToolChoiceOption::None,
ToolChoiceOptions::Auto => ChatCompletionToolChoiceOption::Auto,
ToolChoiceOptions::Required => ChatCompletionToolChoiceOption::Required,
},
ToolChoiceParam::Function(f) => {
ChatCompletionToolChoiceOption::Named(ChatCompletionNamedToolChoice {
r#type: ChatCompletionToolType::Function,
function: FunctionName {
name: names.resolve_choice(None, &f.name)?,
},
})
}
_ => return unsupported_tool(tc, "tool_choice"),
})
}
fn allowed_function_names(
choice: &ToolChoiceAllowed,
names: &ToolNameMap,
) -> anyhow::Result<HashSet<String>> {
let mut allowed = HashSet::new();
for tool in &choice.tools {
let tool_type = tool.get("type").and_then(serde_json::Value::as_str);
let name = tool.get("name").and_then(serde_json::Value::as_str);
let (Some("function"), Some(name)) = (tool_type, name) else {
return Err(ResponsesConversionError::InvalidArgument(
"Responses allowed_tools currently supports only function entries with a string name"
.to_string(),
)
.into());
};
let namespace = match tool.get("namespace") {
None | Some(serde_json::Value::Null) => None,
Some(serde_json::Value::String(namespace)) => Some(namespace.as_str()),
Some(_) => {
return Err(ResponsesConversionError::InvalidArgument(
"Responses allowed_tools namespace must be a string".to_string(),
)
.into());
}
};
allowed.insert(names.resolve_choice(namespace, name)?);
}
if allowed.is_empty() {
return Err(ResponsesConversionError::InvalidArgument(
"Responses allowed_tools must contain at least one function".to_string(),
)
.into());
}
Ok(allowed)
}
fn convert_tools_and_choice(
tools: Option<&[Tool]>,
tool_choice: Option<&ToolChoiceParam>,
names: &ToolNameMap,
) -> anyhow::Result<(
Option<Vec<ChatCompletionTool>>,
Option<ChatCompletionToolChoiceOption>,
)> {
let mut converted_tools = tools
.map(|tools| convert_tools(tools, names))
.transpose()?
.unwrap_or_default();
for tool in &converted_tools {
super::strict_schema::validate_strict_function(&tool.function)
.map_err(|error| ResponsesConversionError::InvalidArgument(error.to_string()))?;
}
let converted_choice = match tool_choice {
Some(ToolChoiceParam::AllowedTools(choice)) => {
let allowed = allowed_function_names(choice, names)?;
let available: HashSet<&str> = converted_tools
.iter()
.map(|tool| tool.function.name.as_str())
.collect();
if let Some(missing) = allowed
.iter()
.find(|name| !available.contains(name.as_str()))
{
return Err(ResponsesConversionError::InvalidArgument(format!(
"Responses allowed_tools references unknown function '{missing}'"
))
.into());
}
converted_tools.retain(|tool| allowed.contains(&tool.function.name));
Some(match choice.mode {
ToolChoiceAllowedMode::Auto => ChatCompletionToolChoiceOption::Auto,
ToolChoiceAllowedMode::Required => ChatCompletionToolChoiceOption::Required,
})
}
Some(choice) => Some(convert_tool_choice(choice, names)?),
None => None,
};
Ok((
(!converted_tools.is_empty()).then_some(converted_tools),
converted_choice,
))
}
pub fn convert_text_format(text: &ResponseTextParam) -> Option<ResponseFormat> {
match &text.format {
TextResponseFormatConfiguration::Text => None,
TextResponseFormatConfiguration::JsonObject => Some(ResponseFormat::JsonObject),
TextResponseFormatConfiguration::JsonSchema(s) => Some(ResponseFormat::JsonSchema {
json_schema: s.clone(),
}),
}
}
fn convert_service_tier(
tier: &ServiceTierResponses,
) -> Result<ChatServiceTier, ResponsesConversionError> {
Ok(match tier {
ServiceTierResponses::Auto => ChatServiceTier::Auto,
ServiceTierResponses::Default => ChatServiceTier::Default,
ServiceTierResponses::Flex => ChatServiceTier::Flex,
ServiceTierResponses::Scale => ChatServiceTier::Scale,
ServiceTierResponses::Priority => ChatServiceTier::Priority,
ServiceTierResponses::Fast => ChatServiceTier::Fast,
ServiceTierResponses::Ultrafast => {
return Err(ResponsesConversionError::UnsupportedContent(
"Responses service_tier 'ultrafast' cannot be represented in Chat Completions"
.to_string(),
));
}
})
}
impl TryFrom<NvCreateResponse> for NvCreateChatCompletionRequest {
type Error = anyhow::Error;
fn try_from(resp: NvCreateResponse) -> Result<Self, Self::Error> {
let names = ToolNameMap::new(
resp.inner.tools.as_deref().unwrap_or_default(),
Some(&resp.inner.input),
);
let mut messages = Vec::new();
if let Some(instructions) = &resp.inner.instructions {
messages.push(ChatCompletionRequestMessage::System(
ChatCompletionRequestSystemMessage {
content: ChatCompletionRequestSystemMessageContent::Text(instructions.clone()),
name: None,
tools: None,
},
));
}
match &resp.inner.input {
InputParam::Text(text) => {
messages.push(ChatCompletionRequestMessage::User(
ChatCompletionRequestUserMessage {
content: ChatCompletionRequestUserMessageContent::Text(text.clone()),
name: None,
},
));
}
InputParam::Items(items) => {
let item_messages = convert_input_items_to_messages(items, &names)?;
messages.extend(item_messages);
}
}
{
let leading_system_count = messages
.iter()
.take_while(|m| matches!(m, ChatCompletionRequestMessage::System(_)))
.count();
if leading_system_count > 1 {
let combined: String = messages[..leading_system_count]
.iter()
.map(|m| match m {
ChatCompletionRequestMessage::System(s) => match &s.content {
ChatCompletionRequestSystemMessageContent::Text(t) => t.as_str(),
other => {
tracing::debug!(
"dropping non-text system message content during leading-system merge: {other:?}"
);
""
}
},
_ => unreachable!(),
})
.collect::<Vec<_>>()
.join("\n\n");
messages.drain(0..leading_system_count);
messages.insert(
0,
ChatCompletionRequestMessage::System(ChatCompletionRequestSystemMessage {
content: ChatCompletionRequestSystemMessageContent::Text(combined),
name: None,
tools: None,
}),
);
}
}
let top_logprobs = convert_top_logprobs(resp.inner.top_logprobs);
let (tools, tool_choice) = convert_tools_and_choice(
resp.inner.tools.as_deref(),
resp.inner.tool_choice.as_ref(),
&names,
)?;
let stream = resp.inner.stream.or(Some(true));
let reasoning_effort = resp
.inner
.reasoning
.as_ref()
.and_then(|r| r.effort.clone())
.map(ChatReasoningEffort::from);
let response_format = resp.inner.text.as_ref().and_then(convert_text_format);
let service_tier = resp
.inner
.service_tier
.as_ref()
.map(convert_service_tier)
.transpose()?;
Ok(NvCreateChatCompletionRequest {
inner: CreateChatCompletionRequest {
messages,
model: resp.inner.model.unwrap_or_default(),
temperature: resp.inner.temperature,
top_p: resp.inner.top_p,
max_completion_tokens: resp.inner.max_output_tokens,
store: resp.inner.store,
parallel_tool_calls: resp.inner.parallel_tool_calls,
top_logprobs,
metadata: resp
.inner
.metadata
.map(|m| serde_json::to_value(m).unwrap_or_default()),
stream,
tools,
tool_choice,
reasoning_effort,
response_format,
service_tier,
..Default::default()
},
common: Default::default(),
nvext: resp.nvext,
chat_template_args: resp.chat_template_args,
thinking: None,
thinking_token_budget: resp.thinking_token_budget,
media_io_kwargs: None,
return_tokens_as_token_ids: None,
unsupported_fields: Default::default(),
})
}
}
fn convert_top_logprobs(input: Option<u8>) -> Option<u8> {
input.map(|x| x.min(20))
}
#[derive(Clone, Debug, Default)]
pub struct ResponseParams {
pub tool_names: Option<ToolNameMap>,
pub model: Option<String>,
pub temperature: Option<f32>,
pub top_p: Option<f32>,
pub max_output_tokens: Option<u32>,
pub metadata: Option<HashMap<String, String>>,
pub parallel_tool_calls: Option<bool>,
pub store: Option<bool>,
pub tools: Option<Vec<Tool>>,
pub tool_choice: Option<ToolChoiceParam>,
pub instructions: Option<String>,
pub reasoning: Option<Reasoning>,
pub text: Option<ResponseTextParam>,
pub service_tier: Option<ServiceTierResponses>,
pub include: Option<Vec<IncludeEnum>>,
pub truncation: Option<Truncation>,
pub presence_penalty: Option<f32>,
pub frequency_penalty: Option<f32>,
pub prompt_cache_key: Option<String>,
pub prompt_cache_retention: Option<PromptCacheRetention>,
pub safety_identifier: Option<String>,
}
pub(crate) fn responses_incomplete_reason(
finish_reason: Option<FinishReason>,
) -> Option<&'static str> {
match finish_reason {
Some(FinishReason::Length) => Some("max_output_tokens"),
Some(FinishReason::ContentFilter) => Some("content_filter"),
_ => None,
}
}
impl ResponseParams {
fn reasoning_summary_requested(&self) -> bool {
self.reasoning
.as_ref()
.and_then(|reasoning| reasoning.summary)
.is_some()
}
fn tool_name_map(&self) -> std::borrow::Cow<'_, ToolNameMap> {
match &self.tool_names {
Some(names) => std::borrow::Cow::Borrowed(names),
None => std::borrow::Cow::Owned(ToolNameMap::new(
self.tools.as_deref().unwrap_or_default(),
None,
)),
}
}
fn allowed_backend_names(&self, names: &ToolNameMap) -> Option<HashSet<String>> {
let Some(ToolChoiceParam::AllowedTools(choice)) = &self.tool_choice else {
return None;
};
Some(allowed_function_names(choice, names).unwrap_or_default())
}
}
pub(super) fn normalize_tools(tools: Vec<Tool>) -> Vec<Tool> {
tools
.into_iter()
.map(|tool| match tool {
Tool::Function(mut ft) => {
if ft.strict.is_none() {
ft.strict = Some(true);
}
Tool::Function(ft)
}
Tool::Namespace(mut namespace) => {
for tool in &mut namespace.tools {
let NamespaceToolParamTool::Function(function) = tool else {
continue;
};
if function.strict.is_none() {
function.strict = Some(true);
}
}
Tool::Namespace(namespace)
}
other => other,
})
.collect()
}
fn make_text_message(id: String, text: String) -> OutputItem {
OutputItem::Message(OutputMessage {
id,
role: AssistantRole::Assistant,
status: OutputStatus::Completed,
phase: None,
content: vec![OutputMessageContent::OutputText(OutputTextContent {
text,
annotations: vec![],
logprobs: Some(vec![]),
})],
})
}
pub fn chat_completion_to_response(
nv_resp: NvCreateChatCompletionResponse,
params: &ResponseParams,
api_context: Option<&crate::protocols::unified::ResponsesContext>,
) -> Result<NvResponse, anyhow::Error> {
let names = params.tool_name_map();
let allowed_names = params.allowed_backend_names(&names);
let nvext = nv_resp.nvext.clone();
let chat_resp = nv_resp.inner;
let message_id = format!("msg_{}", Uuid::new_v4().simple());
let response_id = format!("resp_{}", Uuid::new_v4().simple());
let choice = chat_resp.choices.into_iter().next();
let mut output = Vec::new();
let mut incomplete_reason = None;
if let Some(choice) = choice {
incomplete_reason = responses_incomplete_reason(choice.finish_reason);
if let Some(reasoning_text) = choice.message.reasoning_content
&& !reasoning_text.is_empty()
&& params.reasoning_summary_requested()
{
output.push(OutputItem::Reasoning(ReasoningItem {
id: Some(format!("rs_{}", Uuid::new_v4().simple())),
summary: vec![],
content: Some(vec![ReasoningItemContent::ReasoningText(
ReasoningTextContent {
text: reasoning_text,
},
)]),
encrypted_content: None,
status: Some(OutputStatus::Completed),
}));
}
if let Some(tool_calls) = choice.message.tool_calls {
if let Some(disallowed) = tool_calls.iter().find(|tc| {
allowed_names
.as_ref()
.is_some_and(|allowed| !allowed.contains(&tc.function.name))
}) {
anyhow::bail!(
"Backend returned function '{}' outside allowed_tools",
disallowed.function.name
);
}
for tc in &tool_calls {
let (namespace, name) = names.decode(&tc.function.name);
output.push(OutputItem::FunctionCall(FunctionToolCall {
caller: None,
r#async: None,
arguments: tc.function.arguments.clone(),
call_id: tc.id.clone(),
namespace: namespace.map(str::to_owned),
name: name.to_owned(),
id: Some(format!("fc_{}", Uuid::new_v4().simple())),
status: Some(OutputStatus::Completed),
}));
}
}
let content_text = match choice.message.content {
Some(dynamo_protocols::types::ChatCompletionMessageContent::Text(text)) => Some(text),
Some(dynamo_protocols::types::ChatCompletionMessageContent::Parts(_)) => {
tracing::warn!(
"Multimodal content in responses API not yet supported, using placeholder"
);
Some("[multimodal content]".to_string())
}
None => None,
};
if let Some(content_text) = content_text
&& !content_text.is_empty()
{
output.push(make_text_message(message_id.clone(), content_text));
}
if output.is_empty() {
output.push(make_text_message(message_id, String::new()));
}
} else {
tracing::warn!("No choices in chat completion response, using empty content");
output.push(make_text_message(message_id, String::new()));
}
let keep_logprobs = params
.include
.as_ref()
.is_some_and(|inc| inc.contains(&IncludeEnum::MessageOutputTextLogprobs));
for item in &mut output {
if let OutputItem::Message(msg) = item {
for content in &mut msg.content {
if let OutputMessageContent::OutputText(text) = content
&& (!keep_logprobs || text.logprobs.is_none())
{
text.logprobs = Some(Vec::new());
}
}
}
}
let created_at = chat_resp.created as u64;
let status = if incomplete_reason.is_some() {
Status::Incomplete
} else {
Status::Completed
};
if incomplete_reason.is_some() {
let terminal = output
.iter()
.rposition(|item| matches!(item, OutputItem::Message(_) | OutputItem::FunctionCall(_)));
for (index, item) in output.iter_mut().enumerate() {
match item {
OutputItem::Message(message) if Some(index) == terminal => {
message.status = OutputStatus::Incomplete
}
OutputItem::FunctionCall(call) if Some(index) == terminal => {
call.status = Some(OutputStatus::Incomplete)
}
OutputItem::Reasoning(reasoning) if terminal.is_none() => {
reasoning.status = Some(OutputStatus::Incomplete)
}
_ => {}
}
}
}
let response = Response {
prompt_cache_options: None,
prompt_cache_diagnostics: None,
moderation: None,
id: response_id,
object: "response".to_string(),
created_at,
completed_at: incomplete_reason.is_none().then_some(created_at),
model: if chat_resp.model == "unknown" {
params.model.clone().unwrap_or(chat_resp.model)
} else {
chat_resp.model
},
status,
output,
background: Some(false),
metadata: Some(params.metadata.clone().unwrap_or_default()),
parallel_tool_calls: params.parallel_tool_calls.or(Some(true)),
temperature: params.temperature.or(Some(1.0)),
text: Some(params.text.clone().unwrap_or(ResponseTextParam {
format: TextResponseFormatConfiguration::Text,
verbosity: None,
})),
tool_choice: params
.tool_choice
.clone()
.or(Some(ToolChoiceParam::Option(ToolChoiceOptions::Auto))),
tools: Some(
params
.tools
.clone()
.map(normalize_tools)
.unwrap_or_default(),
),
top_p: params.top_p.or(Some(1.0)),
truncation: Some(params.truncation.unwrap_or(Truncation::Disabled)),
billing: None,
conversation: None,
error: None,
incomplete_details: incomplete_reason.map(|reason| IncompleteDetails {
reason: reason.to_string(),
}),
instructions: params.instructions.clone().map(Instructions::Text),
max_output_tokens: params.max_output_tokens,
previous_response_id: api_context.and_then(|ctx| ctx.previous_response_id.clone()),
prompt: None,
prompt_cache_key: params.prompt_cache_key.clone(),
#[allow(deprecated)]
prompt_cache_retention: params.prompt_cache_retention,
reasoning: params.reasoning.clone(),
safety_identifier: params.safety_identifier.clone(),
service_tier: Some(
params
.service_tier
.clone()
.unwrap_or(ServiceTierResponses::Auto),
),
top_logprobs: Some(0),
usage: chat_resp.usage.map(|u| ResponseUsage {
input_tokens: u.prompt_tokens,
input_tokens_details: InputTokenDetails {
cache_write_tokens: None,
cached_tokens: u
.prompt_tokens_details
.map(|d| d.cached_tokens.unwrap_or(0))
.unwrap_or(0),
},
output_tokens: u.completion_tokens,
output_tokens_details: OutputTokenDetails {
reasoning_tokens: u
.completion_tokens_details
.map(|d| d.reasoning_tokens.unwrap_or(0))
.unwrap_or(0),
},
total_tokens: u.total_tokens,
}),
};
Ok(NvResponse {
inner: response,
nvext,
presence_penalty: params.presence_penalty.unwrap_or(0.0),
frequency_penalty: params.frequency_penalty.unwrap_or(0.0),
store: params.store.unwrap_or(false),
})
}
#[cfg(test)]
mod tests {
use dynamo_protocols::types::responses::{
CreateResponse, EasyInputContent, EasyInputMessage, FunctionCallOutput,
FunctionCallOutputItemParam, FunctionToolCall, InputContent, InputImageContent, InputItem,
InputMessage, InputOutputMessage, InputOutputMessageContent, InputOutputTextContent,
InputParam, InputRole, InputTextContent, Item, MessageItem, Role as ResponseRole,
};
use dynamo_protocols::types::{
ChatCompletionRequestMessage, ChatCompletionRequestUserMessageContent, ImageDetail,
};
use super::*;
use crate::protocols::common::StopConditionsProvider;
use crate::types::openai::chat_completions::NvCreateChatCompletionResponse;
fn make_response_with_input(text: &str) -> NvCreateResponse {
NvCreateResponse {
inner: CreateResponse {
input: InputParam::Text(text.into()),
model: Some("test-model".into()),
max_output_tokens: Some(1024),
temperature: Some(0.5),
top_p: Some(0.9),
top_logprobs: Some(15),
..Default::default()
},
nvext: Some(NvExt {
annotations: Some(vec!["debug".into(), "trace".into()]),
..Default::default()
}),
chat_template_args: None,
..Default::default()
}
}
fn make_response_with_function_output(output: FunctionCallOutput) -> NvCreateResponse {
NvCreateResponse {
inner: CreateResponse {
input: InputParam::Items(vec![InputItem::Item(Item::FunctionCallOutput(
FunctionCallOutputItemParam {
call_id: "call_123".into(),
output,
id: None,
status: None,
},
))]),
model: Some("test-model".into()),
..Default::default()
},
nvext: None,
chat_template_args: None,
thinking_token_budget: None,
}
}
fn requested_reasoning_params() -> ResponseParams {
use dynamo_protocols::types::responses::ReasoningSummary;
ResponseParams {
reasoning: Some(Reasoning {
effort: None,
summary: Some(ReasoningSummary::Auto),
..Default::default()
}),
..Default::default()
}
}
fn reasoning_text(item: &ReasoningItem) -> &str {
let Some(ReasoningItemContent::ReasoningText(content)) =
item.content.as_ref().and_then(|content| content.first())
else {
panic!("expected reasoning text content");
};
&content.text
}
#[test]
fn test_into_nvcreate_chat_completion_request() {
let nv_req: NvCreateChatCompletionRequest =
make_response_with_input("hi there").try_into().unwrap();
assert_eq!(nv_req.inner.model, "test-model");
assert_eq!(nv_req.inner.temperature, Some(0.5));
assert_eq!(nv_req.inner.top_p, Some(0.9));
assert_eq!(nv_req.inner.max_completion_tokens, Some(1024));
assert_eq!(nv_req.inner.top_logprobs, Some(15));
assert_eq!(nv_req.inner.stream, Some(true));
let messages = &nv_req.inner.messages;
assert_eq!(messages.len(), 1);
match &messages[0] {
ChatCompletionRequestMessage::User(user_msg) => match &user_msg.content {
ChatCompletionRequestUserMessageContent::Text(t) => {
assert_eq!(t, "hi there");
}
_ => panic!("unexpected user content type"),
},
_ => panic!("expected user message"),
}
}
#[test]
fn chat_template_args_survive_the_conversion() {
let request: NvCreateResponse = serde_json::from_value(serde_json::json!({
"model": "dummy-model",
"input": "hello",
"chat_template_args": {"enable_thinking": true},
}))
.expect("responses request with chat_template_args should deserialize");
let nv_req: NvCreateChatCompletionRequest = request.try_into().unwrap();
let args = nv_req
.chat_template_args
.expect("chat_template_args should reach the chat request");
assert_eq!(
args.get("enable_thinking"),
Some(&serde_json::Value::Bool(true))
);
}
#[test]
fn chat_template_kwargs_alias_is_accepted() {
let request: NvCreateResponse = serde_json::from_value(serde_json::json!({
"model": "dummy-model",
"input": "hello",
"chat_template_kwargs": {"enable_thinking": true},
}))
.expect("chat_template_kwargs alias should deserialize");
let nv_req: NvCreateChatCompletionRequest = request.try_into().unwrap();
assert!(nv_req.chat_template_args.is_some_and(
|args| args.get("enable_thinking") == Some(&serde_json::Value::Bool(true))
));
}
#[test]
fn absent_chat_template_args_stay_absent() {
let nv_req: NvCreateChatCompletionRequest =
make_response_with_input("hi there").try_into().unwrap();
assert!(nv_req.chat_template_args.is_none());
}
#[test]
fn test_into_chat_completion_preserves_omitted_max_output_tokens() {
let mut response_req = make_response_with_input("hi there");
response_req.inner.max_output_tokens = None;
let nv_req: NvCreateChatCompletionRequest = response_req.try_into().unwrap();
assert_eq!(nv_req.inner.max_completion_tokens, None);
}
#[test]
fn test_store_mapped_to_chat_completion_request() {
let mut req = make_response_with_input("audit me");
req.inner.store = Some(true);
let nv_req: NvCreateChatCompletionRequest = req.try_into().unwrap();
assert_eq!(nv_req.inner.store, Some(true));
}
#[test]
fn test_instructions_prepended_as_system_message() {
let req = NvCreateResponse {
inner: CreateResponse {
input: InputParam::Text("hello".into()),
model: Some("test-model".into()),
instructions: Some("You are a helpful assistant.".into()),
..Default::default()
},
nvext: None,
chat_template_args: None,
..Default::default()
};
let chat_req: NvCreateChatCompletionRequest = req.try_into().unwrap();
let messages = &chat_req.inner.messages;
assert_eq!(messages.len(), 2);
match &messages[0] {
ChatCompletionRequestMessage::System(sys) => match &sys.content {
ChatCompletionRequestSystemMessageContent::Text(t) => {
assert_eq!(t, "You are a helpful assistant.");
}
_ => panic!("expected text content"),
},
_ => panic!("expected system message first"),
}
}
#[test]
fn test_instructions_and_developer_role_merged_into_single_system_message() {
let req = NvCreateResponse {
inner: CreateResponse {
instructions: Some("You are a coding agent.".into()),
input: InputParam::Items(vec![
InputItem::Item(Item::Message(MessageItem::Input(InputMessage {
content: vec![InputContent::InputText(InputTextContent {
prompt_cache_breakpoint: None,
text: "Follow safety guidelines.".into(),
})],
role: InputRole::Developer,
status: None,
}))),
InputItem::Item(Item::Message(MessageItem::Input(InputMessage {
content: vec![InputContent::InputText(InputTextContent {
prompt_cache_breakpoint: None,
text: "What is 2+2?".into(),
})],
role: InputRole::User,
status: None,
}))),
]),
model: Some("test-model".into()),
..Default::default()
},
nvext: None,
chat_template_args: None,
..Default::default()
};
let chat_req: NvCreateChatCompletionRequest = req.try_into().unwrap();
let messages = &chat_req.inner.messages;
assert_eq!(
messages.len(),
2,
"expected merged system + user, got {messages:?}"
);
match &messages[0] {
ChatCompletionRequestMessage::System(sys) => match &sys.content {
ChatCompletionRequestSystemMessageContent::Text(t) => {
assert!(
t.contains("You are a coding agent."),
"merged text missing instructions: {t}"
);
assert!(
t.contains("Follow safety guidelines."),
"merged text missing developer content: {t}"
);
}
_ => panic!("expected text content"),
},
_ => panic!("expected system message at index 0"),
}
assert!(
matches!(messages[1], ChatCompletionRequestMessage::User(_)),
"expected user message at index 1"
);
}
#[test]
fn test_input_items_multi_turn() {
let req = NvCreateResponse {
inner: CreateResponse {
input: InputParam::Items(vec![
InputItem::Item(Item::Message(MessageItem::Input(InputMessage {
content: vec![InputContent::InputText(InputTextContent {
prompt_cache_breakpoint: None,
text: "Be concise.".into(),
})],
role: InputRole::System,
status: None,
}))),
InputItem::Item(Item::Message(MessageItem::Input(InputMessage {
content: vec![InputContent::InputText(InputTextContent {
prompt_cache_breakpoint: None,
text: "What is 2+2?".into(),
})],
role: InputRole::User,
status: None,
}))),
InputItem::Item(Item::Message(MessageItem::Output(InputOutputMessage {
id: Some("msg_1".into()),
role: AssistantRole::Assistant,
status: Some(OutputStatus::Completed),
phase: None,
content: vec![InputOutputMessageContent::OutputText(
InputOutputTextContent {
text: "4".into(),
annotations: vec![],
logprobs: None,
},
)],
}))),
InputItem::Item(Item::Message(MessageItem::Input(InputMessage {
content: vec![InputContent::InputText(InputTextContent {
prompt_cache_breakpoint: None,
text: "And 3+3?".into(),
})],
role: InputRole::User,
status: None,
}))),
]),
model: Some("test-model".into()),
..Default::default()
},
nvext: None,
chat_template_args: None,
..Default::default()
};
let chat_req: NvCreateChatCompletionRequest = req.try_into().unwrap();
let messages = &chat_req.inner.messages;
assert_eq!(messages.len(), 4);
assert!(matches!(
messages[0],
ChatCompletionRequestMessage::System(_)
));
assert!(matches!(messages[1], ChatCompletionRequestMessage::User(_)));
assert!(matches!(
messages[2],
ChatCompletionRequestMessage::Assistant(_)
));
assert!(matches!(messages[3], ChatCompletionRequestMessage::User(_)));
}
#[test]
fn test_input_items_with_file_id_and_image_url_prefers_url() {
let req = NvCreateResponse {
inner: CreateResponse {
input: InputParam::Items(vec![InputItem::Item(Item::Message(MessageItem::Input(
InputMessage {
content: vec![
InputContent::InputText(InputTextContent {
prompt_cache_breakpoint: None,
text: "What is in this image?".into(),
}),
InputContent::InputImage(InputImageContent {
detail: Default::default(), file_id: Some("file_123".into()),
image_url: Some("https://example.com/cat.jpg".into()),
}),
],
role: InputRole::User,
status: None,
},
)))]),
model: Some("test-model".into()),
..Default::default()
},
nvext: None,
chat_template_args: None,
..Default::default()
};
let chat_req: NvCreateChatCompletionRequest = req.try_into().unwrap();
let messages = &chat_req.inner.messages;
assert_eq!(messages.len(), 1);
match &messages[0] {
ChatCompletionRequestMessage::User(u) => match &u.content {
ChatCompletionRequestUserMessageContent::Array(parts) => {
assert_eq!(parts.len(), 2);
}
_ => panic!("expected array content"),
},
_ => panic!("expected user message"),
}
}
#[test]
fn test_easy_message_user_multimodal_preserves_images() {
let req = NvCreateResponse {
inner: CreateResponse {
input: InputParam::Items(vec![InputItem::EasyMessage(EasyInputMessage {
role: ResponseRole::User,
content: EasyInputContent::ContentList(vec![
InputContent::InputText(InputTextContent {
prompt_cache_breakpoint: None,
text: "What is in this image?".into(),
}),
InputContent::InputImage(InputImageContent {
detail: ImageDetail::Original,
file_id: None,
image_url: Some("https://example.com/cat.jpg".into()),
}),
]),
..Default::default()
})]),
model: Some("test-model".into()),
..Default::default()
},
nvext: None,
chat_template_args: None,
..Default::default()
};
let chat_req: NvCreateChatCompletionRequest = req.try_into().unwrap();
let messages = &chat_req.inner.messages;
assert_eq!(messages.len(), 1);
match &messages[0] {
ChatCompletionRequestMessage::User(u) => match &u.content {
ChatCompletionRequestUserMessageContent::Array(parts) => {
assert_eq!(parts.len(), 2, "expected text + image parts to survive");
let has_text = parts.iter().any(|p| {
matches!(
p,
dynamo_protocols::types::ChatCompletionRequestUserMessageContentPart::Text(_)
)
});
let has_image = parts.iter().any(|p| {
matches!(
p,
dynamo_protocols::types::ChatCompletionRequestUserMessageContentPart::ImageUrl(_)
)
});
assert!(has_text, "text part missing");
assert!(has_image, "image part dropped — regression of #9468 review");
let ChatCompletionRequestUserMessageContentPart::ImageUrl(image) = &parts[1]
else {
panic!("expected image part after text");
};
assert_eq!(
image.image_url.as_ref().unwrap().detail,
Some(ImageDetail::Original)
);
}
ChatCompletionRequestUserMessageContent::Text(t) => panic!(
"expected Array content with image preserved, got Text({t:?}) — images were dropped",
),
},
_ => panic!("expected user message"),
}
}
#[test]
fn test_easy_message_user_text_only_stays_text() {
let req = NvCreateResponse {
inner: CreateResponse {
input: InputParam::Items(vec![InputItem::EasyMessage(EasyInputMessage {
role: ResponseRole::User,
content: EasyInputContent::Text("hello".into()),
..Default::default()
})]),
model: Some("test-model".into()),
..Default::default()
},
nvext: None,
chat_template_args: None,
..Default::default()
};
let chat_req: NvCreateChatCompletionRequest = req.try_into().unwrap();
match &chat_req.inner.messages[0] {
ChatCompletionRequestMessage::User(u) => match &u.content {
ChatCompletionRequestUserMessageContent::Text(t) => assert_eq!(t, "hello"),
other => panic!("expected Text user content, got {other:?}"),
},
other => panic!("expected user message, got {other:?}"),
}
}
#[test]
fn test_easy_message_system_contentlist_collapses_to_text() {
let req = NvCreateResponse {
inner: CreateResponse {
input: InputParam::Items(vec![
InputItem::EasyMessage(EasyInputMessage {
role: ResponseRole::System,
content: EasyInputContent::ContentList(vec![InputContent::InputText(
InputTextContent {
prompt_cache_breakpoint: None,
text: "You are helpful.".into(),
},
)]),
..Default::default()
}),
InputItem::EasyMessage(EasyInputMessage {
role: ResponseRole::User,
content: EasyInputContent::Text("hi".into()),
..Default::default()
}),
]),
model: Some("test-model".into()),
..Default::default()
},
nvext: None,
chat_template_args: None,
..Default::default()
};
let chat_req: NvCreateChatCompletionRequest = req.try_into().unwrap();
assert!(matches!(
chat_req.inner.messages[0],
ChatCompletionRequestMessage::System(_)
));
}
#[test]
fn test_easy_message_assistant_contentlist_collapses_to_text() {
let req = NvCreateResponse {
inner: CreateResponse {
input: InputParam::Items(vec![
InputItem::EasyMessage(EasyInputMessage {
role: ResponseRole::Assistant,
content: EasyInputContent::ContentList(vec![InputContent::InputText(
InputTextContent {
prompt_cache_breakpoint: None,
text: "ok".into(),
},
)]),
..Default::default()
}),
InputItem::EasyMessage(EasyInputMessage {
role: ResponseRole::User,
content: EasyInputContent::Text("next".into()),
..Default::default()
}),
]),
model: Some("test-model".into()),
..Default::default()
},
nvext: None,
chat_template_args: None,
..Default::default()
};
let chat_req: NvCreateChatCompletionRequest = req.try_into().unwrap();
let msgs = &chat_req.inner.messages;
assert!(matches!(
msgs[0],
ChatCompletionRequestMessage::Assistant(_)
));
assert!(matches!(msgs[1], ChatCompletionRequestMessage::User(_)));
}
#[test]
fn test_function_call_input_items() {
let req = NvCreateResponse {
inner: CreateResponse {
input: InputParam::Items(vec![
InputItem::Item(Item::Message(MessageItem::Input(InputMessage {
content: vec![InputContent::InputText(InputTextContent {
prompt_cache_breakpoint: None,
text: "What's the weather?".into(),
})],
role: InputRole::User,
status: None,
}))),
InputItem::Item(Item::FunctionCall(FunctionToolCall {
caller: None,
r#async: None,
arguments: r#"{"location":"SF"}"#.into(),
call_id: "call_123".into(),
namespace: None,
name: "get_weather".into(),
id: None,
status: None,
})),
InputItem::Item(Item::FunctionCallOutput(FunctionCallOutputItemParam {
call_id: "call_123".into(),
output: FunctionCallOutput::Content(vec![
InputContent::InputText(InputTextContent {
prompt_cache_breakpoint: None,
text: "{\"temp\":\"".into(),
}),
InputContent::InputText(InputTextContent {
prompt_cache_breakpoint: None,
text: "72F\"}".into(),
}),
]),
id: None,
status: None,
})),
]),
model: Some("test-model".into()),
..Default::default()
},
nvext: None,
chat_template_args: None,
..Default::default()
};
let chat_req: NvCreateChatCompletionRequest = req.try_into().unwrap();
let messages = &chat_req.inner.messages;
assert_eq!(messages.len(), 3);
assert!(matches!(messages[0], ChatCompletionRequestMessage::User(_)));
assert!(matches!(
messages[1],
ChatCompletionRequestMessage::Assistant(_)
));
match &messages[2] {
ChatCompletionRequestMessage::Tool(tool) => {
assert_eq!(tool.tool_call_id, "call_123");
assert!(matches!(
&tool.content,
ChatCompletionRequestToolMessageContent::Text(text)
if text == r#"{"temp":"72F"}"#
));
}
other => panic!("expected tool message, got {other:?}"),
}
}
#[test]
fn test_function_call_output_text_content_is_preserved() {
let output = FunctionCallOutput::Content(vec![
InputContent::InputText(InputTextContent {
prompt_cache_breakpoint: None,
text: "first".into(),
}),
InputContent::InputText(InputTextContent {
prompt_cache_breakpoint: None,
text: " second".into(),
}),
]);
let chat_req: NvCreateChatCompletionRequest = make_response_with_function_output(output)
.try_into()
.unwrap();
let ChatCompletionRequestMessage::Tool(message) = &chat_req.inner.messages[0] else {
panic!("expected tool message");
};
assert_eq!(
message.content,
ChatCompletionRequestToolMessageContent::Text("first second".into())
);
}
#[test]
fn test_function_call_output_image_content_preserves_part_order_and_detail() {
let output: FunctionCallOutput = serde_json::from_value(serde_json::json!([
{"type": "input_text", "text": "Screenshot: "},
{"type": "input_image", "image_url": "data:image/png;base64,aGVsbG8=", "detail": "low"},
{"type": "input_image", "image_url": "https://example.com/original.png", "detail": "original"},
{"type": "input_text", "text": "end"}
]))
.unwrap();
let chat_req: NvCreateChatCompletionRequest = make_response_with_function_output(output)
.try_into()
.unwrap();
let ChatCompletionRequestMessage::Tool(message) = &chat_req.inner.messages[0] else {
panic!("expected tool message");
};
assert_eq!(message.tool_call_id, "call_123");
let ChatCompletionRequestToolMessageContent::Array(parts) = &message.content else {
panic!("expected multimodal tool content");
};
assert_eq!(parts.len(), 4);
assert!(matches!(
&parts[0],
ChatCompletionRequestToolMessageContentPart::Text(text) if text.text == "Screenshot: "
));
let ChatCompletionRequestToolMessageContentPart::ImageUrl(image) = &parts[1] else {
panic!("expected image part");
};
let image_url = image.image_url.as_ref().unwrap();
assert_eq!(image_url.url.as_str(), "data:image/png;base64,aGVsbG8=");
assert_eq!(image_url.detail, Some(ImageDetail::Low));
let ChatCompletionRequestToolMessageContentPart::ImageUrl(image) = &parts[2] else {
panic!("expected second image part");
};
assert_eq!(
image.image_url.as_ref().unwrap().detail,
Some(ImageDetail::Original)
);
assert!(matches!(
&parts[3],
ChatCompletionRequestToolMessageContentPart::Text(text) if text.text == "end"
));
}
#[test]
fn test_function_call_output_content_rejects_files() {
let output: FunctionCallOutput = serde_json::from_value(serde_json::json!([
{"type": "input_text", "text": "Report: "},
{"type": "input_file", "file_data": "data:text/plain;base64,aGVsbG8="}
]))
.unwrap();
let error =
NvCreateChatCompletionRequest::try_from(make_response_with_function_output(output))
.unwrap_err();
assert_eq!(
error.to_string(),
"File function call output content is not yet supported"
);
assert!(matches!(
error.downcast_ref::<ResponsesConversionError>(),
Some(ResponsesConversionError::UnsupportedContent(_))
));
}
#[test]
fn test_function_call_with_interstitial_assistant_message_is_coalesced() {
let req = NvCreateResponse {
inner: CreateResponse {
input: InputParam::Items(vec![
InputItem::Item(Item::Message(MessageItem::Input(InputMessage {
content: vec![InputContent::InputText(InputTextContent {
prompt_cache_breakpoint: None,
text: "What's the weather?".into(),
})],
role: InputRole::User,
status: None,
}))),
InputItem::Item(Item::FunctionCall(FunctionToolCall {
caller: None,
r#async: None,
arguments: r#"{"location":"SF"}"#.into(),
call_id: "call_123".into(),
namespace: None,
name: "get_weather".into(),
id: None,
status: None,
})),
InputItem::Item(Item::Message(MessageItem::Output(InputOutputMessage {
id: Some("msg_interstitial".into()),
role: AssistantRole::Assistant,
status: Some(OutputStatus::Completed),
phase: None,
content: vec![InputOutputMessageContent::OutputText(
InputOutputTextContent {
text: "\n\n".into(),
annotations: vec![],
logprobs: None,
},
)],
}))),
InputItem::Item(Item::FunctionCallOutput(FunctionCallOutputItemParam {
call_id: "call_123".into(),
output: FunctionCallOutput::Text(r#"{"temp":"72F"}"#.into()),
id: None,
status: None,
})),
]),
model: Some("test-model".into()),
..Default::default()
},
nvext: None,
chat_template_args: None,
..Default::default()
};
let chat_req: NvCreateChatCompletionRequest = req.try_into().unwrap();
let messages = &chat_req.inner.messages;
assert_eq!(
messages.len(),
3,
"expected coalesced [user, assistant, tool]"
);
assert!(matches!(messages[0], ChatCompletionRequestMessage::User(_)));
match &messages[1] {
ChatCompletionRequestMessage::Assistant(a) => {
let tool_calls = a.tool_calls.as_ref().expect("tool_calls must be present");
assert_eq!(tool_calls.len(), 1);
assert_eq!(tool_calls[0].id, "call_123");
assert_eq!(tool_calls[0].function.name, "get_weather");
match a
.content
.as_ref()
.expect("content must carry interstitial text")
{
ChatCompletionRequestAssistantMessageContent::Text(t) => {
assert_eq!(t, "\n\n");
}
_ => panic!("expected text content"),
}
}
_ => panic!("expected a single merged assistant message at index 1"),
}
assert!(matches!(messages[2], ChatCompletionRequestMessage::Tool(_)));
}
#[test]
fn test_easy_message_assistant_coalesced_with_adjacent_function_call() {
use dynamo_protocols::types::responses::{
EasyInputContent, EasyInputMessage, Role as ResponseRole,
};
let req = NvCreateResponse {
inner: CreateResponse {
input: InputParam::Items(vec![
InputItem::EasyMessage(EasyInputMessage {
role: ResponseRole::User,
content: EasyInputContent::Text("x".into()),
..Default::default()
}),
InputItem::Item(Item::FunctionCall(FunctionToolCall {
caller: None,
r#async: None,
arguments: "{}".into(),
call_id: "c".into(),
namespace: None,
name: "f".into(),
id: None,
status: None,
})),
InputItem::EasyMessage(EasyInputMessage {
role: ResponseRole::Assistant,
content: EasyInputContent::Text("".into()),
..Default::default()
}),
InputItem::Item(Item::FunctionCallOutput(FunctionCallOutputItemParam {
call_id: "c".into(),
output: FunctionCallOutput::Text("x".into()),
id: None,
status: None,
})),
]),
model: Some("test-model".into()),
..Default::default()
},
nvext: None,
chat_template_args: None,
..Default::default()
};
let chat_req: NvCreateChatCompletionRequest = req.try_into().unwrap();
let messages = &chat_req.inner.messages;
assert_eq!(messages.len(), 3);
match &messages[1] {
ChatCompletionRequestMessage::Assistant(a) => {
assert!(a.tool_calls.is_some());
assert_eq!(a.tool_calls.as_ref().unwrap().len(), 1);
}
_ => panic!("expected merged assistant message"),
}
assert!(matches!(messages[2], ChatCompletionRequestMessage::Tool(_)));
}
#[test]
fn test_standalone_assistant_message_with_empty_content_preserves_turn() {
let req = NvCreateResponse {
inner: CreateResponse {
input: InputParam::Items(vec![
InputItem::Item(Item::Message(MessageItem::Input(InputMessage {
content: vec![InputContent::InputText(InputTextContent {
prompt_cache_breakpoint: None,
text: "first question".into(),
})],
role: InputRole::User,
status: None,
}))),
InputItem::Item(Item::Message(MessageItem::Output(InputOutputMessage {
id: None,
role: AssistantRole::Assistant,
status: None,
phase: None,
content: vec![InputOutputMessageContent::OutputText(
InputOutputTextContent {
text: "".into(),
annotations: vec![],
logprobs: None,
},
)],
}))),
InputItem::Item(Item::Message(MessageItem::Input(InputMessage {
content: vec![InputContent::InputText(InputTextContent {
prompt_cache_breakpoint: None,
text: "second question".into(),
})],
role: InputRole::User,
status: None,
}))),
]),
model: Some("test-model".into()),
..Default::default()
},
nvext: None,
chat_template_args: None,
..Default::default()
};
let chat_req: NvCreateChatCompletionRequest = req.try_into().unwrap();
let messages = &chat_req.inner.messages;
assert_eq!(
messages.len(),
3,
"empty assistant turn must not be silently dropped"
);
assert!(matches!(messages[0], ChatCompletionRequestMessage::User(_)));
match &messages[1] {
ChatCompletionRequestMessage::Assistant(a) => {
assert!(a.tool_calls.is_none());
match a.content.as_ref().expect("empty turn still emits content") {
ChatCompletionRequestAssistantMessageContent::Text(t) => {
assert_eq!(t, "");
}
_ => panic!("expected text content"),
}
}
_ => panic!("expected assistant turn boundary preserved"),
}
assert!(matches!(messages[2], ChatCompletionRequestMessage::User(_)));
}
#[test]
fn test_easy_assistant_message_with_empty_content_preserves_turn() {
use dynamo_protocols::types::responses::{
EasyInputContent, EasyInputMessage, Role as ResponseRole,
};
let req = NvCreateResponse {
inner: CreateResponse {
input: InputParam::Items(vec![
InputItem::EasyMessage(EasyInputMessage {
role: ResponseRole::User,
content: EasyInputContent::Text("first".into()),
..Default::default()
}),
InputItem::EasyMessage(EasyInputMessage {
role: ResponseRole::Assistant,
content: EasyInputContent::Text("".into()),
..Default::default()
}),
InputItem::EasyMessage(EasyInputMessage {
role: ResponseRole::User,
content: EasyInputContent::Text("second".into()),
..Default::default()
}),
]),
model: Some("test-model".into()),
..Default::default()
},
nvext: None,
chat_template_args: None,
..Default::default()
};
let chat_req: NvCreateChatCompletionRequest = req.try_into().unwrap();
let messages = &chat_req.inner.messages;
assert_eq!(messages.len(), 3);
assert!(matches!(
messages[1],
ChatCompletionRequestMessage::Assistant(_)
));
}
#[test]
fn test_pure_function_call_turn_emits_null_content() {
let req = NvCreateResponse {
inner: CreateResponse {
input: InputParam::Items(vec![
InputItem::Item(Item::Message(MessageItem::Input(InputMessage {
content: vec![InputContent::InputText(InputTextContent {
prompt_cache_breakpoint: None,
text: "hi".into(),
})],
role: InputRole::User,
status: None,
}))),
InputItem::Item(Item::FunctionCall(FunctionToolCall {
caller: None,
r#async: None,
arguments: "{}".into(),
call_id: "c".into(),
namespace: None,
name: "f".into(),
id: None,
status: None,
})),
InputItem::Item(Item::FunctionCallOutput(FunctionCallOutputItemParam {
call_id: "c".into(),
output: FunctionCallOutput::Text("ok".into()),
id: None,
status: None,
})),
]),
model: Some("test-model".into()),
..Default::default()
},
nvext: None,
chat_template_args: None,
..Default::default()
};
let chat_req: NvCreateChatCompletionRequest = req.try_into().unwrap();
let messages = &chat_req.inner.messages;
assert_eq!(messages.len(), 3);
match &messages[1] {
ChatCompletionRequestMessage::Assistant(a) => {
assert!(
a.content.is_none(),
"pure tool-call turn must have content: null, got {:?}",
a.content
);
assert!(a.tool_calls.is_some());
}
_ => panic!("expected assistant message"),
}
}
#[test]
fn test_reasoning_item_replay_prefers_content_with_summary_fallback() {
use dynamo_protocols::types::responses::{
InputReasoningItem, ReasoningTextContent, SummaryPart, SummaryTextContent,
};
let cases = [
(
InputReasoningItem {
id: Some("rs_summary".into()),
summary: vec![SummaryPart::SummaryText(SummaryTextContent {
text: "summary reasoning".into(),
})],
content: None,
encrypted_content: None,
status: None,
},
"summary reasoning",
),
(
InputReasoningItem {
id: Some("rs_content".into()),
summary: vec![SummaryPart::SummaryText(SummaryTextContent {
text: "fallback summary".into(),
})],
content: Some(vec![ReasoningTextContent {
text: "raw reasoning".into(),
}]),
encrypted_content: None,
status: None,
},
"raw reasoning",
),
];
for (reasoning, expected) in cases {
let req = NvCreateResponse {
inner: CreateResponse {
input: InputParam::Items(vec![InputItem::Item(Item::Reasoning(reasoning))]),
model: Some("test-model".into()),
..Default::default()
},
nvext: None,
chat_template_args: None,
thinking_token_budget: None,
};
let chat_req: NvCreateChatCompletionRequest = req.try_into().unwrap();
assert!(matches!(
&chat_req.inner.messages[0],
ChatCompletionRequestMessage::Assistant(message)
if matches!(
message.reasoning_content.as_ref(),
Some(ReasoningContent::Text(text)) if text == expected
)
));
}
}
#[test]
fn test_interleaved_reasoning_and_tool_calls_preserve_segments() {
use dynamo_protocols::types::responses::{InputReasoningItem, ReasoningTextContent};
let reasoning = |id: &str, text: &str| {
InputItem::Item(Item::Reasoning(InputReasoningItem {
id: Some(id.into()),
summary: vec![],
content: Some(vec![ReasoningTextContent { text: text.into() }]),
encrypted_content: None,
status: None,
}))
};
let tool_call = |call_id: &str, name: &str| {
InputItem::Item(Item::FunctionCall(FunctionToolCall {
caller: None,
r#async: None,
arguments: "{}".into(),
call_id: call_id.into(),
namespace: None,
name: name.into(),
id: None,
status: None,
}))
};
let req = NvCreateResponse {
inner: CreateResponse {
input: InputParam::Items(vec![
reasoning("rs_1", "first thought"),
tool_call("call_1", "first_tool"),
reasoning("rs_2", "second thought"),
tool_call("call_2", "second_tool"),
]),
model: Some("test-model".into()),
..Default::default()
},
nvext: None,
chat_template_args: None,
..Default::default()
};
let chat_req: NvCreateChatCompletionRequest = req.try_into().unwrap();
let ChatCompletionRequestMessage::Assistant(message) = &chat_req.inner.messages[0] else {
panic!("expected assistant message");
};
assert_eq!(
message.reasoning_content,
Some(ReasoningContent::Segments(vec![
"first thought".into(),
"second thought".into(),
String::new(),
]))
);
assert_eq!(message.tool_calls.as_ref().unwrap().len(), 2);
}
#[test]
fn test_unsupported_item_variant_flushes_pending() {
use dynamo_protocols::types::responses::{
ComputerCallOutputItemParam, ComputerScreenshotImage, ComputerScreenshotImageType,
};
let req = NvCreateResponse {
inner: CreateResponse {
input: InputParam::Items(vec![
InputItem::Item(Item::Message(MessageItem::Input(InputMessage {
content: vec![InputContent::InputText(InputTextContent {
prompt_cache_breakpoint: None,
text: "go".into(),
})],
role: InputRole::User,
status: None,
}))),
InputItem::Item(Item::FunctionCall(FunctionToolCall {
caller: None,
r#async: None,
arguments: "{}".into(),
call_id: "c1".into(),
namespace: None,
name: "f".into(),
id: None,
status: None,
})),
InputItem::Item(Item::ComputerCallOutput(ComputerCallOutputItemParam {
call_id: "cc1".into(),
output: ComputerScreenshotImage {
r#type: ComputerScreenshotImageType::ComputerScreenshot,
image_url: None,
file_id: None,
},
acknowledged_safety_checks: None,
id: None,
status: None,
})),
InputItem::Item(Item::FunctionCall(FunctionToolCall {
caller: None,
r#async: None,
arguments: "{}".into(),
call_id: "c2".into(),
namespace: None,
name: "f".into(),
id: None,
status: None,
})),
InputItem::Item(Item::FunctionCallOutput(FunctionCallOutputItemParam {
call_id: "c2".into(),
output: FunctionCallOutput::Text("ok".into()),
id: None,
status: None,
})),
]),
model: Some("test-model".into()),
..Default::default()
},
nvext: None,
chat_template_args: None,
..Default::default()
};
let chat_req: NvCreateChatCompletionRequest = req.try_into().unwrap();
let messages = &chat_req.inner.messages;
assert!(messages.len() >= 4, "catch-all must flush pending");
let tc_msgs: Vec<_> = messages
.iter()
.filter_map(|m| match m {
ChatCompletionRequestMessage::Assistant(a) => a.tool_calls.as_ref(),
_ => None,
})
.collect();
assert_eq!(
tc_msgs.len(),
2,
"two tool-call turns must not coalesce across unsupported variant"
);
assert_eq!(tc_msgs[0].len(), 1);
assert_eq!(tc_msgs[0][0].id, "c1");
assert_eq!(tc_msgs[1].len(), 1);
assert_eq!(tc_msgs[1][0].id, "c2");
}
#[test]
fn test_function_call_then_output_text_then_output_merges_to_one_turn() {
let req = NvCreateResponse {
inner: CreateResponse {
input: InputParam::Items(vec![
InputItem::Item(Item::Message(MessageItem::Input(InputMessage {
content: vec![InputContent::InputText(InputTextContent {
prompt_cache_breakpoint: None,
text: "call say".into(),
})],
role: InputRole::User,
status: None,
}))),
InputItem::Item(Item::FunctionCall(FunctionToolCall {
caller: None,
r#async: None,
arguments: r#"{"x":"hi"}"#.into(),
call_id: "c".into(),
namespace: None,
name: "say".into(),
id: None,
status: None,
})),
InputItem::Item(Item::Message(MessageItem::Output(InputOutputMessage {
id: None,
role: AssistantRole::Assistant,
status: None,
phase: None,
content: vec![InputOutputMessageContent::OutputText(
InputOutputTextContent {
text: "\n\n\n".into(),
annotations: vec![],
logprobs: None,
},
)],
}))),
InputItem::Item(Item::FunctionCallOutput(FunctionCallOutputItemParam {
call_id: "c".into(),
output: FunctionCallOutput::Text("hi".into()),
id: None,
status: None,
})),
]),
model: Some("test-model".into()),
..Default::default()
},
nvext: None,
chat_template_args: None,
..Default::default()
};
let chat_req: NvCreateChatCompletionRequest = req.try_into().unwrap();
let messages = &chat_req.inner.messages;
assert_eq!(messages.len(), 3);
assert!(matches!(messages[0], ChatCompletionRequestMessage::User(_)));
match &messages[1] {
ChatCompletionRequestMessage::Assistant(a) => {
let tool_calls = a.tool_calls.as_ref().expect("tool_calls present");
assert_eq!(tool_calls.len(), 1);
assert_eq!(tool_calls[0].id, "c");
match a.content.as_ref().expect("text content present") {
ChatCompletionRequestAssistantMessageContent::Text(t) => {
assert_eq!(t, "\n\n\n");
}
_ => panic!("expected text content"),
}
}
_ => panic!("expected merged assistant message"),
}
assert!(matches!(messages[2], ChatCompletionRequestMessage::Tool(_)));
}
#[test]
fn test_output_text_then_function_call_then_output_merges_to_one_turn() {
let req = NvCreateResponse {
inner: CreateResponse {
input: InputParam::Items(vec![
InputItem::Item(Item::Message(MessageItem::Input(InputMessage {
content: vec![InputContent::InputText(InputTextContent {
prompt_cache_breakpoint: None,
text: "call say".into(),
})],
role: InputRole::User,
status: None,
}))),
InputItem::Item(Item::Message(MessageItem::Output(InputOutputMessage {
id: None,
role: AssistantRole::Assistant,
status: None,
phase: None,
content: vec![InputOutputMessageContent::OutputText(
InputOutputTextContent {
text: "let me call it".into(),
annotations: vec![],
logprobs: None,
},
)],
}))),
InputItem::Item(Item::FunctionCall(FunctionToolCall {
caller: None,
r#async: None,
arguments: r#"{"x":"hi"}"#.into(),
call_id: "c".into(),
namespace: None,
name: "say".into(),
id: None,
status: None,
})),
InputItem::Item(Item::FunctionCallOutput(FunctionCallOutputItemParam {
call_id: "c".into(),
output: FunctionCallOutput::Text("hi".into()),
id: None,
status: None,
})),
]),
model: Some("test-model".into()),
..Default::default()
},
nvext: None,
chat_template_args: None,
..Default::default()
};
let chat_req: NvCreateChatCompletionRequest = req.try_into().unwrap();
let messages = &chat_req.inner.messages;
assert_eq!(messages.len(), 3);
match &messages[1] {
ChatCompletionRequestMessage::Assistant(a) => {
assert_eq!(a.tool_calls.as_ref().expect("tool_calls present").len(), 1);
match a.content.as_ref().expect("content present") {
ChatCompletionRequestAssistantMessageContent::Text(t) => {
assert_eq!(t, "let me call it");
}
_ => panic!("expected text content"),
}
}
_ => panic!("expected merged assistant message"),
}
}
#[test]
fn test_multiple_function_calls_merge_into_single_assistant_message() {
let req = NvCreateResponse {
inner: CreateResponse {
input: InputParam::Items(vec![
InputItem::Item(Item::Message(MessageItem::Input(InputMessage {
content: vec![InputContent::InputText(InputTextContent {
prompt_cache_breakpoint: None,
text: "do two things".into(),
})],
role: InputRole::User,
status: None,
}))),
InputItem::Item(Item::FunctionCall(FunctionToolCall {
caller: None,
r#async: None,
arguments: "{}".into(),
call_id: "c1".into(),
namespace: None,
name: "f".into(),
id: None,
status: None,
})),
InputItem::Item(Item::FunctionCall(FunctionToolCall {
caller: None,
r#async: None,
arguments: "{}".into(),
call_id: "c2".into(),
namespace: None,
name: "g".into(),
id: None,
status: None,
})),
InputItem::Item(Item::FunctionCallOutput(FunctionCallOutputItemParam {
call_id: "c1".into(),
output: FunctionCallOutput::Text("r1".into()),
id: None,
status: None,
})),
InputItem::Item(Item::FunctionCallOutput(FunctionCallOutputItemParam {
call_id: "c2".into(),
output: FunctionCallOutput::Text("r2".into()),
id: None,
status: None,
})),
]),
model: Some("test-model".into()),
..Default::default()
},
nvext: None,
chat_template_args: None,
..Default::default()
};
let chat_req: NvCreateChatCompletionRequest = req.try_into().unwrap();
let messages = &chat_req.inner.messages;
assert_eq!(messages.len(), 4);
match &messages[1] {
ChatCompletionRequestMessage::Assistant(a) => {
let tool_calls = a.tool_calls.as_ref().expect("tool_calls present");
assert_eq!(tool_calls.len(), 2, "parallel tool_calls must coalesce");
assert_eq!(tool_calls[0].id, "c1");
assert_eq!(tool_calls[1].id, "c2");
assert!(a.content.is_none(), "pure-tool-call turn has null content");
}
_ => panic!("expected single merged assistant message"),
}
assert!(matches!(messages[2], ChatCompletionRequestMessage::Tool(_)));
assert!(matches!(messages[3], ChatCompletionRequestMessage::Tool(_)));
}
#[test]
fn test_refusal_content_folded_into_assistant_text() {
use dynamo_protocols::types::responses::RefusalContent;
let req = NvCreateResponse {
inner: CreateResponse {
input: InputParam::Items(vec![
InputItem::Item(Item::Message(MessageItem::Input(InputMessage {
content: vec![InputContent::InputText(InputTextContent {
prompt_cache_breakpoint: None,
text: "try again".into(),
})],
role: InputRole::User,
status: None,
}))),
InputItem::Item(Item::Message(MessageItem::Output(InputOutputMessage {
id: None,
role: AssistantRole::Assistant,
status: None,
phase: None,
content: vec![InputOutputMessageContent::Refusal(RefusalContent {
refusal: "I cannot help with that.".into(),
})],
}))),
InputItem::Item(Item::Message(MessageItem::Input(InputMessage {
content: vec![InputContent::InputText(InputTextContent {
prompt_cache_breakpoint: None,
text: "ok different question".into(),
})],
role: InputRole::User,
status: None,
}))),
]),
model: Some("test-model".into()),
..Default::default()
},
nvext: None,
chat_template_args: None,
..Default::default()
};
let chat_req: NvCreateChatCompletionRequest = req.try_into().unwrap();
let messages = &chat_req.inner.messages;
assert_eq!(messages.len(), 3);
match &messages[1] {
ChatCompletionRequestMessage::Assistant(a) => {
match a.content.as_ref().expect("refusal folded into content") {
ChatCompletionRequestAssistantMessageContent::Text(t) => {
assert_eq!(t, "I cannot help with that.");
}
_ => panic!("expected text content"),
}
}
_ => panic!("expected assistant message carrying folded refusal"),
}
}
#[test]
fn test_top_level_and_namespace_function_tools_conversion() {
let mut req = make_response_with_input("hello");
req.inner.tools = Some(
serde_json::from_value(serde_json::json!([
{
"type": "function",
"name": "get_weather",
"description": "Get weather info",
"parameters": {
"type": "object",
"properties": {
"location": {"type": "string"}
},
"required": ["location"],
"additionalProperties": false
},
"strict": true
},
{
"type": "namespace",
"name": "multi_agent_v1",
"description": "Subagent tools",
"tools": [
{
"type": "function",
"name": "spawn_agent",
"parameters": {"type": "object"}
}
]
}
]))
.unwrap(),
);
let chat_req: NvCreateChatCompletionRequest = req.try_into().unwrap();
let tools = chat_req.inner.tools.expect("tools should reach backend");
assert_eq!(tools.len(), 2);
assert_eq!(tools[0].function.name, "get_weather");
assert_eq!(tools[1].function.name, "spawn_agent");
}
#[test]
fn test_namespace_function_name_collision_round_trips() {
let mut req = make_response_with_input("hello");
req.inner.tools = Some(
serde_json::from_value(serde_json::json!([
{
"type": "namespace",
"name": "crm",
"description": "CRM tools",
"tools": [{"type": "function", "name": "lookup"}]
},
{
"type": "namespace",
"name": "billing",
"description": "Billing tools",
"tools": [{"type": "function", "name": "lookup"}]
}
]))
.unwrap(),
);
let names = ToolNameMap::new(req.inner.tools.as_deref().unwrap(), None);
let chat = NvCreateChatCompletionRequest::try_from(req).unwrap();
let tools = chat.inner.tools.unwrap();
assert_ne!(tools[0].function.name, tools[1].function.name);
assert_eq!(
names.decode(&tools[0].function.name),
(Some("crm"), "lookup")
);
assert_eq!(
names.decode(&tools[1].function.name),
(Some("billing"), "lookup")
);
}
#[test]
fn test_strict_schema_errors_are_invalid_arguments_before_filtering() {
let tools: Vec<Tool> = serde_json::from_value(serde_json::json!([
{"type": "function", "name": "retained"},
{"type": "function", "name": "excluded", "strict": true, "parameters": {"type": "object"}}
])).unwrap();
let choice = serde_json::from_value(serde_json::json!({
"type": "allowed_tools", "mode": "auto",
"tools": [{"type": "function", "name": "retained"}]
}))
.unwrap();
let names = ToolNameMap::new(&tools, None);
let error = convert_tools_and_choice(Some(&tools), Some(&choice), &names).unwrap_err();
assert!(matches!(
error.downcast_ref::<ResponsesConversionError>(),
Some(ResponsesConversionError::InvalidArgument(_))
));
assert_eq!(
error.to_string(),
"Invalid schema for function 'excluded': In context=#, object schemas require additionalProperties: false"
);
}
#[test]
fn test_excluded_non_strict_tools_keep_existing_validation_behavior() {
let choice = serde_json::from_value(serde_json::json!({
"type": "allowed_tools", "mode": "auto",
"tools": [{"type": "function", "name": "retained"}]
}))
.unwrap();
for strict in [None, Some(false)] {
let mut tools = vec![
serde_json::from_value::<Tool>(serde_json::json!({
"type": "function", "name": "retained"
}))
.unwrap(),
];
for i in 0..=super::super::validate::MAX_TOOLS {
tools.push(
serde_json::from_value(serde_json::json!({
"type": "function", "name": format!("invalid name {i} {}", "x".repeat(129)),
"parameters": 42, "strict": strict
}))
.unwrap(),
);
}
let names = ToolNameMap::new(&tools, None);
let (converted, _) =
convert_tools_and_choice(Some(&tools), Some(&choice), &names).unwrap();
let converted = converted.unwrap();
assert_eq!(converted.len(), 1);
assert_eq!(converted[0].function.name, "retained");
super::super::validate::validate_tools(&Some(&converted)).unwrap();
}
}
#[test]
fn test_top_level_namespace_function_name_collision_round_trips() {
let mut req = make_response_with_input("hello");
req.inner.tools = Some(
serde_json::from_value(serde_json::json!([
{"type": "function", "name": "lookup"},
{
"type": "namespace",
"name": "crm",
"description": "CRM tools",
"tools": [{"type": "function", "name": "lookup"}]
}
]))
.unwrap(),
);
req.inner.tool_choice = Some(
serde_json::from_value(serde_json::json!({
"type": "function", "name": "lookup"
}))
.unwrap(),
);
let names = ToolNameMap::new(req.inner.tools.as_deref().unwrap(), None);
let chat = NvCreateChatCompletionRequest::try_from(req).unwrap();
let tools = chat.inner.tools.unwrap();
assert_ne!(tools[0].function.name, tools[1].function.name);
assert_eq!(names.decode(&tools[0].function.name), (None, "lookup"));
assert_eq!(
names.decode(&tools[1].function.name),
(Some("crm"), "lookup")
);
let Some(ChatCompletionToolChoiceOption::Named(choice)) = chat.inner.tool_choice else {
panic!("expected named choice");
};
assert_eq!(choice.function.name, tools[0].function.name);
}
#[test]
fn test_namespace_aliases_preserve_history_and_allowed_tools() {
let req: NvCreateResponse = serde_json::from_value(serde_json::json!({
"model": "test-model",
"input": [
{"type": "function_call", "call_id": "old", "namespace": "removed",
"name": "lookup", "arguments": "{}"},
{"type": "function_call_output", "call_id": "old", "output": "ok"},
{"type": "function_call", "call_id": "current", "namespace": "crm",
"name": "lookup", "arguments": "{}"}
],
"tools": [
{"type": "function", "name": "crm__lookup"},
{"type": "namespace", "name": "crm", "description": "CRM", "tools": [
{"type": "function", "name": "lookup"}
]},
{"type": "namespace", "name": "billing", "description": "Billing", "tools": [
{"type": "function", "name": "lookup"}
]}
],
"tool_choice": {"type": "allowed_tools", "mode": "required", "tools": [
{"type": "function", "namespace": "crm", "name": "lookup"}
]}
}))
.unwrap();
let names = ToolNameMap::new(req.inner.tools.as_deref().unwrap(), Some(&req.inner.input));
let chat = NvCreateChatCompletionRequest::try_from(req.clone()).unwrap();
let tools = chat.inner.tools.unwrap();
assert_eq!(tools.len(), 1);
let alias = &tools[0].function.name;
assert_ne!(alias, "crm__lookup");
assert_eq!(names.decode(alias), (Some("crm"), "lookup"));
let ChatCompletionRequestMessage::Assistant(first) = &chat.inner.messages[0] else {
panic!("expected historical call");
};
let old_alias = &first.tool_calls.as_ref().unwrap()[0].function.name;
assert_ne!(old_alias, alias);
assert_eq!(names.decode(old_alias), (Some("removed"), "lookup"));
let ChatCompletionRequestMessage::Assistant(last) = chat.inner.messages.last().unwrap()
else {
panic!("expected current call");
};
assert_eq!(&last.tool_calls.as_ref().unwrap()[0].function.name, alias);
let params = ResponseParams {
tools: req.inner.tools.clone(),
tool_choice: req.inner.tool_choice.clone(),
tool_names: Some(names.clone()),
..Default::default()
};
let allowed_names = params.allowed_backend_names(&names).unwrap();
assert!(allowed_names.contains(alias));
assert!(!allowed_names.contains(&names.encode(Some("billing"), "lookup")));
let mut internal_alias = req.clone();
internal_alias.inner.tool_choice = Some(
serde_json::from_value(serde_json::json!({
"type": "allowed_tools", "mode": "auto", "tools": [
{"type": "function", "name": alias}
]
}))
.unwrap(),
);
assert!(
NvCreateChatCompletionRequest::try_from(internal_alias)
.unwrap_err()
.to_string()
.contains("unknown function")
);
let mut ambiguous = req;
ambiguous.inner.tool_choice = Some(
serde_json::from_value(serde_json::json!({
"type": "function", "name": "lookup"
}))
.unwrap(),
);
assert!(
NvCreateChatCompletionRequest::try_from(ambiguous)
.unwrap_err()
.to_string()
.contains("ambiguous")
);
}
#[test]
fn test_named_choice_ignores_removed_historical_tools() {
let req: NvCreateResponse = serde_json::from_value(serde_json::json!({
"model": "test-model",
"input": [
{"type": "function_call", "call_id": "old", "name": "lookup", "arguments": "{}"},
{"type": "function_call_output", "call_id": "old", "output": "ok"}
],
"tools": [{"type": "namespace", "name": "crm", "description": "CRM", "tools": [
{"type": "function", "name": "lookup"}
]}],
"tool_choice": {"type": "function", "name": "lookup"}
}))
.unwrap();
let chat = NvCreateChatCompletionRequest::try_from(req).unwrap();
let Some(ChatCompletionToolChoiceOption::Named(choice)) = chat.inner.tool_choice else {
panic!("expected named choice");
};
assert_eq!(
choice.function.name,
chat.inner.tools.unwrap()[0].function.name
);
let ChatCompletionRequestMessage::Assistant(history) = &chat.inner.messages[0] else {
panic!("expected historical call");
};
assert_ne!(
history.tool_calls.as_ref().unwrap()[0].function.name,
choice.function.name
);
}
#[test]
fn test_duplicate_top_level_function_tools_are_preserved() {
let mut req = make_response_with_input("hello");
req.inner.tools = Some(
serde_json::from_value(serde_json::json!([
{"type": "function", "name": "lookup"},
{"type": "function", "name": "lookup"}
]))
.unwrap(),
);
let chat_req: NvCreateChatCompletionRequest = req.try_into().unwrap();
assert_eq!(chat_req.inner.tools.unwrap().len(), 2);
}
#[test]
fn test_duplicate_namespace_function_tools_are_preserved() {
let mut req = make_response_with_input("hello");
req.inner.tools = Some(
serde_json::from_value(serde_json::json!([
{
"type": "namespace",
"name": "crm",
"description": "CRM tools",
"tools": [
{"type": "function", "name": "lookup"},
{"type": "function", "name": "lookup"}
]
}
]))
.unwrap(),
);
let chat_req: NvCreateChatCompletionRequest = req.try_into().unwrap();
assert_eq!(chat_req.inner.tools.unwrap().len(), 2);
}
#[test]
fn test_normalize_tools_sets_namespace_function_strict() {
let tools = serde_json::from_value(serde_json::json!([{
"type": "namespace",
"name": "agents",
"description": "Subagent tools",
"tools": [{"type": "function", "name": "spawn_agent"}]
}]))
.unwrap();
let normalized = serde_json::to_value(normalize_tools(tools)).unwrap();
assert_eq!(normalized[0]["tools"][0]["strict"], true);
}
#[allow(deprecated)]
#[test]
fn test_into_nvresponse_from_chat_response() {
let now = 1_726_000_000;
let chat_resp = NvCreateChatCompletionResponse {
inner: dynamo_protocols::types::CreateChatCompletionResponse {
id: "chatcmpl-xyz".into(),
choices: vec![dynamo_protocols::types::ChatChoice {
index: 0,
message: dynamo_protocols::types::ChatCompletionResponseMessage {
content: Some(dynamo_protocols::types::ChatCompletionMessageContent::Text(
"This is a reply".to_string(),
)),
refusal: None,
tool_calls: None,
role: dynamo_protocols::types::Role::Assistant,
function_call: None,
audio: None,
reasoning_content: None,
},
finish_reason: None,
logprobs: None,
}],
created: now,
model: "llama-3.1-8b-instruct".into(),
service_tier: None,
system_fingerprint: None,
object: "chat.completion".to_string(),
usage: None,
},
prompt_logprobs: None,
nvext: None,
};
let wrapped =
chat_completion_to_response(chat_resp, &ResponseParams::default(), None).unwrap();
assert_eq!(wrapped.inner.model, "llama-3.1-8b-instruct");
assert_eq!(wrapped.inner.status, Status::Completed);
assert_eq!(wrapped.inner.object, "response");
assert!(wrapped.inner.id.starts_with("resp_"));
let msg = match &wrapped.inner.output[0] {
OutputItem::Message(m) => m,
_ => panic!("Expected Message variant"),
};
assert_eq!(msg.role, AssistantRole::Assistant);
match &msg.content[0] {
OutputMessageContent::OutputText(txt) => {
assert_eq!(txt.text, "This is a reply");
}
_ => panic!("Expected OutputText content"),
}
}
#[allow(deprecated)]
#[test]
fn test_response_with_tool_calls() {
let now = 1_726_000_000;
let chat_resp = NvCreateChatCompletionResponse {
inner: dynamo_protocols::types::CreateChatCompletionResponse {
id: "chatcmpl-xyz".into(),
choices: vec![dynamo_protocols::types::ChatChoice {
index: 0,
message: dynamo_protocols::types::ChatCompletionResponseMessage {
content: None,
refusal: None,
tool_calls: Some(vec![ChatCompletionMessageToolCall {
id: "call_abc".into(),
r#type: FunctionType::Function,
function: dynamo_protocols::types::FunctionCall {
name: "get_weather".into(),
arguments: r#"{"location":"SF"}"#.into(),
},
}]),
role: dynamo_protocols::types::Role::Assistant,
function_call: None,
audio: None,
reasoning_content: Some("Need the weather tool".into()),
},
finish_reason: None,
logprobs: None,
}],
created: now,
model: "test-model".into(),
service_tier: None,
system_fingerprint: None,
object: "chat.completion".to_string(),
usage: None,
},
prompt_logprobs: None,
nvext: None,
};
let mut params = requested_reasoning_params();
params.tool_choice = Some(
serde_json::from_value(serde_json::json!({
"type": "allowed_tools",
"mode": "auto",
"tools": [{"type": "function", "name": "get_weather"}]
}))
.unwrap(),
);
let wrapped = chat_completion_to_response(chat_resp.clone(), ¶ms, None).unwrap();
assert_eq!(wrapped.inner.output.len(), 2);
let OutputItem::Reasoning(reasoning) = &wrapped.inner.output[0] else {
panic!("Expected Reasoning output before the tool call");
};
assert!(reasoning.summary.is_empty());
assert_eq!(reasoning_text(reasoning), "Need the weather tool");
match &wrapped.inner.output[1] {
OutputItem::FunctionCall(fc) => {
assert_eq!(fc.call_id, "call_abc");
assert_eq!(fc.name, "get_weather");
}
_ => panic!("Expected FunctionCall output"),
}
params.tool_choice = Some(
serde_json::from_value(serde_json::json!({
"type": "allowed_tools",
"mode": "auto",
"tools": [{"type": "function", "name": "delete_file"}]
}))
.unwrap(),
);
let error = chat_completion_to_response(chat_resp, ¶ms, None).unwrap_err();
assert!(error.to_string().contains("outside allowed_tools"));
}
#[allow(deprecated)]
#[test]
fn test_response_with_namespaced_tool_call() {
let chat_resp = NvCreateChatCompletionResponse {
inner: dynamo_protocols::types::CreateChatCompletionResponse {
id: "chatcmpl-xyz".into(),
choices: vec![dynamo_protocols::types::ChatChoice {
index: 0,
message: dynamo_protocols::types::ChatCompletionResponseMessage {
content: None,
refusal: None,
tool_calls: Some(vec![ChatCompletionMessageToolCall {
id: "call_abc".into(),
r#type: dynamo_protocols::types::FunctionType::Function,
function: dynamo_protocols::types::FunctionCall {
name: "spawn_agent".into(),
arguments: r#"{"agent_type":"worker"}"#.into(),
},
}]),
role: dynamo_protocols::types::Role::Assistant,
function_call: None,
audio: None,
reasoning_content: None,
},
finish_reason: None,
logprobs: None,
}],
created: 0,
model: "test-model".into(),
service_tier: None,
system_fingerprint: None,
object: "chat.completion".into(),
usage: None,
},
prompt_logprobs: None,
nvext: None,
};
let params = ResponseParams {
tools: Some(
serde_json::from_value(serde_json::json!([{
"type": "namespace",
"name": "agents",
"description": "Subagent tools",
"tools": [{"type": "function", "name": "spawn_agent"}],
}]))
.unwrap(),
),
..Default::default()
};
let response = chat_completion_to_response(chat_resp, ¶ms, None).unwrap();
let OutputItem::FunctionCall(call) = &response.inner.output[0] else {
panic!("expected function call");
};
assert_eq!(call.namespace.as_deref(), Some("agents"));
}
#[test]
fn test_convert_top_logprobs_clamped() {
assert_eq!(convert_top_logprobs(Some(5)), Some(5));
assert_eq!(convert_top_logprobs(Some(21)), Some(20));
assert_eq!(convert_top_logprobs(Some(255)), Some(20));
assert_eq!(convert_top_logprobs(None), None);
}
#[test]
fn test_response_preserves_literal_tool_markup_alongside_structured_call() {
let text = r#"Example: <think>reasoning</think><tool_call>{"name":"get_weather","arguments":{}}</tool_call>"#;
let params = ResponseParams {
tools: Some(
serde_json::from_value(serde_json::json!([{
"type": "namespace", "name": "weather", "description": "Weather tools",
"tools": [{"type": "function", "name": "get_weather"}]
}]))
.unwrap(),
),
tool_choice: Some(ToolChoiceParam::Option(ToolChoiceOptions::Auto)),
..Default::default()
};
let mut chat = make_chat_resp_with_text(text);
chat.inner.choices[0].message.tool_calls = Some(vec![ChatCompletionMessageToolCall {
id: "call_original".into(),
r#type: FunctionType::Function,
function: dynamo_protocols::types::FunctionCall {
name: "get_weather".into(),
arguments: r#"{"city":"Paris"}"#.into(),
},
}]);
chat.inner.choices[0].finish_reason = Some(FinishReason::ToolCalls);
let response = chat_completion_to_response(chat, ¶ms, None).unwrap();
assert_eq!(response.inner.output.len(), 2);
let OutputItem::FunctionCall(call) = &response.inner.output[0] else {
panic!("expected the structured function call");
};
assert_eq!(call.call_id, "call_original");
assert_eq!(call.name, "get_weather");
assert_eq!(call.namespace.as_deref(), Some("weather"));
assert_eq!(call.arguments, r#"{"city":"Paris"}"#);
let OutputItem::Message(message) = &response.inner.output[1] else {
panic!("expected the original text");
};
let OutputMessageContent::OutputText(content) = &message.content[0] else {
panic!("expected output text");
};
assert_eq!(content.text, text);
}
#[test]
fn test_reasoning_effort_mapped_to_chat_completion() {
use dynamo_protocols::types::responses::Reasoning;
let mut req = make_response_with_input("think hard");
req.inner.reasoning = Some(Reasoning {
effort: Some(serde_json::from_value(serde_json::json!("medium")).unwrap()),
..Default::default()
});
let chat: NvCreateChatCompletionRequest = req.try_into().unwrap();
assert_eq!(
chat.inner.reasoning_effort,
Some(ChatReasoningEffort::Medium)
);
}
#[test]
fn test_reasoning_none_leaves_chat_field_none() {
let req = make_response_with_input("no reasoning");
let chat: NvCreateChatCompletionRequest = req.try_into().unwrap();
assert_eq!(chat.inner.reasoning_effort, None);
}
#[test]
fn test_text_format_json_object_mapped() {
use dynamo_protocols::types::ResponseFormat;
use dynamo_protocols::types::responses::{
ResponseTextParam, TextResponseFormatConfiguration,
};
let mut req = make_response_with_input("give json");
req.inner.text = Some(ResponseTextParam {
format: TextResponseFormatConfiguration::JsonObject,
verbosity: None,
});
let chat: NvCreateChatCompletionRequest = req.try_into().unwrap();
assert_eq!(chat.inner.response_format, Some(ResponseFormat::JsonObject));
}
#[test]
fn test_text_format_json_schema_mapped() {
use dynamo_protocols::types::responses::{
ResponseTextParam, TextResponseFormatConfiguration,
};
use dynamo_protocols::types::{ResponseFormat, ResponseFormatJsonSchema};
let schema = ResponseFormatJsonSchema {
name: "city".into(),
description: None,
schema: serde_json::json!({"type": "object"}),
strict: Some(true),
};
let mut req = make_response_with_input("structured");
req.inner.text = Some(ResponseTextParam {
format: TextResponseFormatConfiguration::JsonSchema(schema.clone()),
verbosity: None,
});
let chat: NvCreateChatCompletionRequest = req.try_into().unwrap();
assert_eq!(
chat.inner.response_format,
Some(ResponseFormat::JsonSchema {
json_schema: schema
})
);
}
#[test]
fn test_text_format_plain_text_leaves_response_format_none() {
use dynamo_protocols::types::responses::{
ResponseTextParam, TextResponseFormatConfiguration,
};
let mut req = make_response_with_input("plain");
req.inner.text = Some(ResponseTextParam {
format: TextResponseFormatConfiguration::Text,
verbosity: None,
});
let chat: NvCreateChatCompletionRequest = req.try_into().unwrap();
assert_eq!(chat.inner.response_format, None);
}
#[test]
fn test_service_tier_mapped_to_chat_completion() {
use dynamo_protocols::types::ServiceTier as ChatServiceTier;
use dynamo_protocols::types::responses::ServiceTierResponses as RespServiceTier;
for (tier, expected) in [
(RespServiceTier::Priority, ChatServiceTier::Priority),
(RespServiceTier::Fast, ChatServiceTier::Fast),
] {
let mut req = make_response_with_input("service tier");
req.inner.service_tier = Some(tier);
let chat: NvCreateChatCompletionRequest = req.try_into().unwrap();
assert_eq!(chat.inner.service_tier, Some(expected));
}
let mut req = make_response_with_input("unsupported service tier");
req.inner.service_tier = Some(RespServiceTier::Ultrafast);
let error = NvCreateChatCompletionRequest::try_from(req).unwrap_err();
assert!(matches!(
error.downcast_ref::<ResponsesConversionError>(),
Some(ResponsesConversionError::UnsupportedContent(_))
));
}
#[test]
fn test_parallel_tool_calls_mapped_to_chat_completion() {
let mut req = make_response_with_input("parallel tools off");
req.inner.parallel_tool_calls = Some(false);
let chat: NvCreateChatCompletionRequest = req.try_into().unwrap();
assert_eq!(chat.inner.parallel_tool_calls, Some(false));
}
#[test]
fn test_response_echoes_reasoning() {
use dynamo_protocols::types::responses::Reasoning;
let params = ResponseParams {
reasoning: Some(Reasoning {
effort: Some(serde_json::from_value(serde_json::json!("high")).unwrap()),
..Default::default()
}),
..Default::default()
};
let chat_resp = NvCreateChatCompletionResponse {
inner: dynamo_protocols::types::CreateChatCompletionResponse {
choices: vec![],
created: 0,
id: "test".into(),
model: "m".into(),
service_tier: None,
system_fingerprint: None,
object: "chat.completion".into(),
usage: None,
},
prompt_logprobs: None,
nvext: None,
};
let resp = chat_completion_to_response(chat_resp, ¶ms, None).unwrap();
let reasoning = resp.inner.reasoning.unwrap();
assert_eq!(
serde_json::to_value(reasoning.effort).unwrap(),
serde_json::json!("high")
);
}
#[test]
fn test_response_echoes_text_format() {
use dynamo_protocols::types::responses::{
ResponseTextParam, TextResponseFormatConfiguration,
};
let params = ResponseParams {
text: Some(ResponseTextParam {
format: TextResponseFormatConfiguration::JsonObject,
verbosity: None,
}),
..Default::default()
};
let chat_resp = NvCreateChatCompletionResponse {
inner: dynamo_protocols::types::CreateChatCompletionResponse {
choices: vec![],
created: 0,
id: "test".into(),
model: "m".into(),
service_tier: None,
system_fingerprint: None,
object: "chat.completion".into(),
usage: None,
},
prompt_logprobs: None,
nvext: None,
};
let resp = chat_completion_to_response(chat_resp, ¶ms, None).unwrap();
let text = resp.inner.text.unwrap();
assert_eq!(text.format, TextResponseFormatConfiguration::JsonObject);
}
#[test]
fn test_response_echoes_service_tier() {
use dynamo_protocols::types::responses::ServiceTierResponses;
let params = ResponseParams {
service_tier: Some(ServiceTierResponses::Flex),
..Default::default()
};
let chat_resp = NvCreateChatCompletionResponse {
inner: dynamo_protocols::types::CreateChatCompletionResponse {
choices: vec![],
created: 0,
id: "test".into(),
model: "m".into(),
service_tier: None,
system_fingerprint: None,
object: "chat.completion".into(),
usage: None,
},
prompt_logprobs: None,
nvext: None,
};
let resp = chat_completion_to_response(chat_resp, ¶ms, None).unwrap();
assert_eq!(resp.inner.service_tier, Some(ServiceTierResponses::Flex));
}
#[test]
fn test_response_echoes_parallel_tool_calls() {
let params = ResponseParams {
parallel_tool_calls: Some(false),
..Default::default()
};
let chat_resp = NvCreateChatCompletionResponse {
inner: dynamo_protocols::types::CreateChatCompletionResponse {
choices: vec![],
created: 0,
id: "test".into(),
model: "m".into(),
service_tier: None,
system_fingerprint: None,
object: "chat.completion".into(),
usage: None,
},
prompt_logprobs: None,
nvext: None,
};
let resp = chat_completion_to_response(chat_resp, ¶ms, None).unwrap();
assert_eq!(resp.inner.parallel_tool_calls, Some(false));
}
#[test]
fn test_bare_assistant_output_message_deserializes_via_owned_types() {
let json = serde_json::json!({
"role": "assistant",
"content": [{"type": "output_text", "text": "Hello!"}],
"type": "message"
});
let item: InputItem =
serde_json::from_value(json).expect("relaxed deserialize should succeed");
match item {
InputItem::Item(Item::Message(MessageItem::Output(msg))) => {
assert_eq!(msg.role, AssistantRole::Assistant);
assert!(msg.id.is_none());
assert!(msg.status.is_none());
}
other => panic!("Expected Item::Message(Output), got {:?}", other),
}
}
#[test]
fn test_nvcreate_response_accepts_bare_assistant_messages() {
let body = serde_json::json!({
"model": "m",
"input": [
{"type": "message", "role": "user", "content": [
{"type": "input_text", "text": "hi"}
]},
{"type": "function_call", "call_id": "c", "name": "f", "arguments": "{}"},
{"type": "message", "role": "assistant", "content": [
{"type": "output_text", "text": "\n\n\n"}
]},
{"type": "function_call_output", "call_id": "c", "output": "x"}
]
});
let req: NvCreateResponse =
serde_json::from_value(body).expect("relaxed deserialize should succeed");
let items = match &req.inner.input {
InputParam::Items(items) => items,
_ => panic!("expected Items input"),
};
assert_eq!(items.len(), 4);
match &items[2] {
InputItem::Item(Item::Message(MessageItem::Output(out))) => {
assert_eq!(out.role, AssistantRole::Assistant);
}
other => panic!("expected MessageItem::Output, got {:?}", other),
}
}
#[test]
fn test_nvcreate_response_normalizes_codex_agent_message() {
let body = serde_json::json!({
"model": "m",
"input": [{
"type": "agent_message",
"author": "/root",
"recipient": "/root/dynamo_subagent_smoke",
"content": [
{"type": "input_text", "text": "First."},
{"type": "input_text", "text": "Second."},
],
}],
});
let req: NvCreateResponse = serde_json::from_value(body).unwrap();
let chat_req: NvCreateChatCompletionRequest = req.try_into().unwrap();
let [ChatCompletionRequestMessage::User(message)] = &chat_req.inner.messages[..] else {
panic!("expected one user message");
};
assert_eq!(
message.content,
ChatCompletionRequestUserMessageContent::Text("First.\nSecond.".to_string())
);
}
#[test]
fn test_nvcreate_response_normalizes_string_codex_agent_message() {
let body = serde_json::json!({
"model": "m",
"input": [{
"type": "agent_message",
"author": "/root",
"recipient": "/root/dynamo_subagent_smoke",
"content": "Return exactly OK.",
}],
});
let req: NvCreateResponse = serde_json::from_value(body).unwrap();
let chat_req: NvCreateChatCompletionRequest = req.try_into().unwrap();
let [ChatCompletionRequestMessage::User(message)] = &chat_req.inner.messages[..] else {
panic!("expected one user message");
};
assert_eq!(
message.content,
ChatCompletionRequestUserMessageContent::Text("Return exactly OK.".to_string())
);
}
#[test]
fn test_output_message_with_id_and_status_still_works() {
use dynamo_protocols::types::responses::{InputItem, Item, MessageItem, OutputStatus};
let json = serde_json::json!({
"role": "assistant",
"id": "msg_abc123",
"status": "completed",
"content": [{"type": "output_text", "text": "Hello!", "annotations": []}],
"type": "message"
});
let item: InputItem = serde_json::from_value(json).unwrap();
match item {
InputItem::Item(Item::Message(MessageItem::Output(msg))) => {
assert_eq!(msg.id.as_deref(), Some("msg_abc123"));
assert_eq!(msg.status, Some(OutputStatus::Completed));
}
other => panic!("Expected Item::Message(Output), got {:?}", other),
}
}
fn make_chat_resp_with_text(text: &str) -> NvCreateChatCompletionResponse {
use dynamo_protocols::types::{
ChatChoice, ChatCompletionMessageContent, ChatCompletionResponseMessage, FinishReason,
};
NvCreateChatCompletionResponse {
inner: dynamo_protocols::types::CreateChatCompletionResponse {
choices: vec![ChatChoice {
index: 0,
#[allow(deprecated)]
message: ChatCompletionResponseMessage {
content: Some(ChatCompletionMessageContent::Text(text.into())),
role: dynamo_protocols::types::Role::Assistant,
tool_calls: None,
refusal: None,
reasoning_content: None,
function_call: None,
audio: None,
},
finish_reason: Some(FinishReason::Stop),
logprobs: None,
}],
created: 0,
id: "test".into(),
model: "m".into(),
service_tier: None,
system_fingerprint: None,
object: "chat.completion".into(),
usage: None,
},
prompt_logprobs: None,
nvext: None,
}
}
fn make_chat_resp_with_reasoning(reasoning: &str) -> NvCreateChatCompletionResponse {
let mut response = make_chat_resp_with_text("answer");
response.inner.choices[0].message.reasoning_content = Some(reasoning.into());
response
}
fn make_chat_resp_with_tool_call(
finish_reason: dynamo_protocols::types::FinishReason,
arguments: &str,
) -> NvCreateChatCompletionResponse {
make_chat_resp_with_tool_calls(finish_reason, &[arguments])
}
fn make_chat_resp_with_tool_calls(
finish_reason: dynamo_protocols::types::FinishReason,
arguments: &[&str],
) -> NvCreateChatCompletionResponse {
let mut response = make_chat_resp_with_text("");
let choice = &mut response.inner.choices[0];
choice.finish_reason = Some(finish_reason);
choice.message.content = None;
choice.message.tool_calls = Some(
arguments
.iter()
.enumerate()
.map(|(index, arguments)| ChatCompletionMessageToolCall {
id: format!("call_abc{index}"),
r#type: FunctionType::Function,
function: dynamo_protocols::types::FunctionCall {
name: "get_weather".into(),
arguments: (*arguments).into(),
},
})
.collect(),
);
response
}
#[test]
fn test_reasoning_text_requires_explicit_request() {
let unrequested = chat_completion_to_response(
make_chat_resp_with_reasoning("private reasoning"),
&ResponseParams::default(),
None,
)
.unwrap();
assert!(
unrequested
.inner
.output
.iter()
.all(|item| !matches!(item, OutputItem::Reasoning(_)))
);
let params = requested_reasoning_params();
let requested =
chat_completion_to_response(make_chat_resp_with_reasoning("summary"), ¶ms, None)
.unwrap();
let reasoning = requested
.inner
.output
.iter()
.find_map(|item| match item {
OutputItem::Reasoning(reasoning) => Some(reasoning),
_ => None,
})
.expect("requested reasoning output");
assert!(reasoning.summary.is_empty());
assert_eq!(reasoning_text(reasoning), "summary");
}
#[test]
fn test_reasoning_summary_precedes_structured_tool_calls() {
use dynamo_protocols::types::responses::{Reasoning, ReasoningSummary};
let mut chat_resp = make_chat_resp_with_reasoning("look up the weather");
let message = &mut chat_resp.inner.choices[0].message;
message.content = None;
message.tool_calls = Some(vec![ChatCompletionMessageToolCall {
id: "call_weather".into(),
r#type: FunctionType::Function,
function: dynamo_protocols::types::FunctionCall {
name: "get_weather".into(),
arguments: r#"{"location":"SF"}"#.into(),
},
}]);
let params = ResponseParams {
reasoning: Some(Reasoning {
effort: None,
summary: Some(ReasoningSummary::Auto),
..Default::default()
}),
..Default::default()
};
let response = chat_completion_to_response(chat_resp, ¶ms, None).unwrap();
assert!(matches!(
response.inner.output.as_slice(),
[OutputItem::Reasoning(_), OutputItem::FunctionCall(_)]
));
}
#[test]
fn test_include_logprobs_empty_by_default() {
let chat_resp = make_chat_resp_with_text("hello");
let params = ResponseParams::default();
let resp = chat_completion_to_response(chat_resp, ¶ms, None).unwrap();
for item in &resp.inner.output {
if let OutputItem::Message(msg) = item {
for content in &msg.content {
if let OutputMessageContent::OutputText(t) = content {
assert_eq!(
t.logprobs.as_deref(),
Some(&[][..]),
"logprobs should be an empty array by default"
);
}
}
}
}
}
#[test]
fn test_include_logprobs_kept_when_requested() {
use dynamo_protocols::types::responses::IncludeEnum;
let chat_resp = make_chat_resp_with_text("hello");
let params = ResponseParams {
include: Some(vec![IncludeEnum::MessageOutputTextLogprobs]),
..Default::default()
};
let resp = chat_completion_to_response(chat_resp, ¶ms, None).unwrap();
let mut found_text = false;
for item in &resp.inner.output {
if let OutputItem::Message(msg) = item {
for content in &msg.content {
if let OutputMessageContent::OutputText(t) = content {
found_text = true;
assert!(
t.logprobs.is_some(),
"logprobs should be preserved when included"
);
}
}
}
}
assert!(found_text, "Expected text output");
}
#[test]
fn test_truncation_auto_echoed_back() {
use dynamo_protocols::types::responses::Truncation;
let chat_resp = make_chat_resp_with_text("hello");
let params = ResponseParams {
truncation: Some(Truncation::Auto),
..Default::default()
};
let resp = chat_completion_to_response(chat_resp, ¶ms, None).unwrap();
assert_eq!(resp.inner.truncation, Some(Truncation::Auto));
}
#[test]
fn test_truncation_defaults_to_disabled() {
let chat_resp = make_chat_resp_with_text("hello");
let params = ResponseParams::default();
let resp = chat_completion_to_response(chat_resp, ¶ms, None).unwrap();
assert_eq!(resp.inner.truncation, Some(Truncation::Disabled));
}
#[test]
fn test_length_finish_reason_returns_incomplete_response() {
let mut chat_resp = make_chat_resp_with_text("partial");
chat_resp.inner.choices[0].finish_reason =
Some(dynamo_protocols::types::FinishReason::Length);
let resp =
chat_completion_to_response(chat_resp, &ResponseParams::default(), None).unwrap();
assert_eq!(resp.inner.status, Status::Incomplete);
assert_eq!(resp.inner.completed_at, None);
assert_eq!(
resp.inner
.incomplete_details
.as_ref()
.map(|details| details.reason.as_str()),
Some("max_output_tokens")
);
let OutputItem::Message(message) = &resp.inner.output[0] else {
panic!("expected message output");
};
assert_eq!(message.status, OutputStatus::Incomplete);
}
#[test]
fn test_tool_calls_finish_reason_returns_completed_response() {
let chat_resp = make_chat_resp_with_tool_call(
dynamo_protocols::types::FinishReason::ToolCalls,
r#"{"location":"SF"}"#,
);
let response =
chat_completion_to_response(chat_resp, &ResponseParams::default(), None).unwrap();
assert_eq!(response.inner.status, Status::Completed);
assert!(response.inner.incomplete_details.is_none());
let OutputItem::FunctionCall(call) = &response.inner.output[0] else {
panic!("expected function call output");
};
assert_eq!(call.status, Some(OutputStatus::Completed));
}
#[test]
fn test_content_filter_returns_non_success_response() {
let chat_resp = make_chat_resp_with_text("blocked");
let mut chat_resp = chat_resp;
chat_resp.inner.choices[0].finish_reason =
Some(dynamo_protocols::types::FinishReason::ContentFilter);
let response =
chat_completion_to_response(chat_resp, &ResponseParams::default(), None).unwrap();
assert_eq!(response.inner.status, Status::Incomplete);
assert_eq!(response.inner.completed_at, None);
assert_eq!(
response
.inner
.incomplete_details
.as_ref()
.map(|details| details.reason.as_str()),
Some("content_filter")
);
let OutputItem::Message(message) = &response.inner.output[0] else {
panic!("expected message output");
};
assert_eq!(message.status, OutputStatus::Incomplete);
}
#[test]
fn test_unmodified_length_with_tool_call_returns_incomplete_response() {
let chat_resp = make_chat_resp_with_tool_call(
dynamo_protocols::types::FinishReason::Length,
r#"{"location":"SF"#,
);
let response =
chat_completion_to_response(chat_resp, &ResponseParams::default(), None).unwrap();
assert_eq!(response.inner.status, Status::Incomplete);
assert_eq!(
response
.inner
.incomplete_details
.as_ref()
.map(|details| details.reason.as_str()),
Some("max_output_tokens")
);
let OutputItem::FunctionCall(call) = &response.inner.output[0] else {
panic!("expected function call output");
};
assert_eq!(call.status, Some(OutputStatus::Incomplete));
}
#[test]
fn test_length_marks_only_the_terminal_tool_call_incomplete() {
let chat_resp = make_chat_resp_with_tool_calls(
dynamo_protocols::types::FinishReason::Length,
&[r#"{"location":"SF"}"#, r#"{"location":"NY"#],
);
let response =
chat_completion_to_response(chat_resp, &ResponseParams::default(), None).unwrap();
assert_eq!(response.inner.status, Status::Incomplete);
let statuses: Vec<_> = response
.inner
.output
.iter()
.filter_map(|item| match item {
OutputItem::FunctionCall(call) => Some(call.status),
_ => None,
})
.collect();
assert_eq!(
statuses,
vec![
Some(OutputStatus::Completed),
Some(OutputStatus::Incomplete)
]
);
}
#[test]
fn test_length_finish_reason_preserves_completed_reasoning_status() {
use dynamo_protocols::types::responses::{Reasoning, ReasoningSummary};
let mut chat_resp = make_chat_resp_with_reasoning("complete reasoning");
chat_resp.inner.choices[0].finish_reason =
Some(dynamo_protocols::types::FinishReason::Length);
let params = ResponseParams {
reasoning: Some(Reasoning {
effort: None,
summary: Some(ReasoningSummary::Auto),
..Default::default()
}),
..Default::default()
};
let response = chat_completion_to_response(chat_resp, ¶ms, None)
.unwrap()
.inner;
assert_eq!(response.status, Status::Incomplete);
let OutputItem::Reasoning(reasoning) = &response.output[0] else {
panic!("expected reasoning output");
};
assert_eq!(reasoning.status, Some(OutputStatus::Completed));
let OutputItem::Message(message) = &response.output[1] else {
panic!("expected message output");
};
assert_eq!(message.status, OutputStatus::Incomplete);
}
#[test]
fn test_length_finish_reason_marks_terminal_reasoning_incomplete() {
use dynamo_protocols::types::responses::{Reasoning, ReasoningSummary};
let mut chat_resp = make_chat_resp_with_reasoning("partial reasoning");
chat_resp.inner.choices[0].message.content = None;
chat_resp.inner.choices[0].finish_reason =
Some(dynamo_protocols::types::FinishReason::Length);
let params = ResponseParams {
reasoning: Some(Reasoning {
effort: None,
summary: Some(ReasoningSummary::Auto),
..Default::default()
}),
..Default::default()
};
let response = chat_completion_to_response(chat_resp, ¶ms, None)
.unwrap()
.inner;
assert_eq!(response.status, Status::Incomplete);
let OutputItem::Reasoning(reasoning) = &response.output[0] else {
panic!("expected reasoning output");
};
assert_eq!(reasoning.status, Some(OutputStatus::Incomplete));
}
#[test]
fn test_response_echoes_passthrough_metadata() {
let chat_resp = make_chat_resp_with_text("hello");
let params = ResponseParams {
prompt_cache_key: Some("cache-key-codex-1".into()),
prompt_cache_retention: Some(PromptCacheRetention::InMemory),
safety_identifier: Some("user-abc".into()),
..Default::default()
};
let resp = chat_completion_to_response(chat_resp, ¶ms, None).unwrap();
assert_eq!(
resp.inner.prompt_cache_key.as_deref(),
Some("cache-key-codex-1")
);
#[allow(deprecated)]
let retention = resp.inner.prompt_cache_retention;
assert_eq!(retention, Some(PromptCacheRetention::InMemory));
assert_eq!(resp.inner.safety_identifier.as_deref(), Some("user-abc"));
}
#[test]
fn test_response_wire_format_shape() {
let mut chat_resp = make_chat_resp_with_text("hello");
chat_resp.inner.choices[0].message.reasoning_content = Some("raw reasoning".into());
let params = requested_reasoning_params();
let resp = chat_completion_to_response(chat_resp, ¶ms, None).unwrap();
let json = serde_json::to_value(&resp).unwrap();
assert_eq!(json["frequency_penalty"], 0.0);
assert_eq!(json["presence_penalty"], 0.0);
assert_eq!(json["store"], false);
assert_eq!(json["object"], "response");
assert_eq!(json["status"], "completed");
assert_eq!(json["metadata"], serde_json::json!({}));
assert!(json["output"].is_array());
assert!(json["output"][0].get("id").is_some());
assert!(json["output"][0].get("status").is_some());
assert_eq!(json["output"][0]["type"], "reasoning");
assert_eq!(json["output"][0]["summary"], serde_json::json!([]));
assert_eq!(
json["output"][0]["content"][0],
serde_json::json!({"type": "reasoning_text", "text": "raw reasoning"})
);
assert_eq!(json["output"][1]["type"], "message");
for key in [
"error",
"incomplete_details",
"billing",
"conversation",
"safety_identifier",
"max_tool_calls",
"instructions",
"previous_response_id",
"prompt_cache_key",
] {
assert_eq!(
json.get(key),
Some(&serde_json::Value::Null),
"expected {key} to be present as null"
);
}
assert!(json.get("nvext").is_none());
}
#[test]
fn test_thinking_token_budget_preserved_in_chat_completion_conversion() {
let mut req = make_response_with_input("hi there");
req.thinking_token_budget = Some(32);
let nv_req: NvCreateChatCompletionRequest = req.try_into().unwrap();
assert_eq!(nv_req.thinking_token_budget, Some(32));
assert_eq!(
nv_req
.extract_stop_conditions()
.expect("failed to extract stop conditions")
.max_thinking_tokens,
Some(32)
);
}
#[test]
fn test_thinking_token_budget_overrides_nvext_in_response_conversion() {
let mut req = make_response_with_input("hi there");
req.thinking_token_budget = Some(32);
req.nvext = Some(NvExt {
max_thinking_tokens: Some(16),
..Default::default()
});
let nv_req: NvCreateChatCompletionRequest = req.try_into().unwrap();
assert_eq!(
nv_req
.extract_stop_conditions()
.expect("failed to extract stop conditions")
.max_thinking_tokens,
Some(32)
);
}
#[test]
fn test_nvext_max_thinking_tokens_fallback_in_response_conversion() {
let mut req = make_response_with_input("hi there");
req.nvext = Some(NvExt {
max_thinking_tokens: Some(16),
..Default::default()
});
let nv_req: NvCreateChatCompletionRequest = req.try_into().unwrap();
assert_eq!(
nv_req
.extract_stop_conditions()
.expect("failed to extract stop conditions")
.max_thinking_tokens,
Some(16)
);
}
}