pub mod stream_converter;
use dynamo_async_openai::types::responses::{
AssistantRole, FunctionCallOutput, FunctionToolCall, IncludeEnum, InputContent, InputItem,
InputParam, InputRole, InputTokenDetails, Instructions, Item, MessageItem, OutputItem,
OutputMessage, OutputMessageContent, OutputStatus, OutputTextContent, OutputTokenDetails,
Reasoning, ReasoningItem, Response, ResponseTextParam, ResponseUsage, Role as ResponseRole,
ServiceTier, Status, Summary, SummaryPart, TextResponseFormatConfiguration, Tool,
ToolChoiceOptions, ToolChoiceParam, Truncation,
};
use dynamo_async_openai::types::{
ChatCompletionMessageToolCall, ChatCompletionNamedToolChoice,
ChatCompletionRequestAssistantMessage, ChatCompletionRequestAssistantMessageContent,
ChatCompletionRequestMessage, ChatCompletionRequestMessageContentPartImage,
ChatCompletionRequestMessageContentPartText, ChatCompletionRequestMessageContentPartVideo,
ChatCompletionRequestSystemMessage, ChatCompletionRequestSystemMessageContent,
ChatCompletionRequestToolMessage, ChatCompletionRequestToolMessageContent,
ChatCompletionRequestUserMessage, ChatCompletionRequestUserMessageContent,
ChatCompletionRequestUserMessageContentPart, ChatCompletionTool,
ChatCompletionToolChoiceOption, ChatCompletionToolType, CreateChatCompletionRequest,
FunctionName, FunctionObject, ImageDetail as ChatImageDetail, ImageUrl, ResponseFormat,
ServiceTier as ChatServiceTier, VideoUrl,
};
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::nvext::{NvExt, NvExtProvider};
use super::{OpenAISamplingOptionsProvider, OpenAIStopConditionsProvider};
#[derive(ToSchema, Serialize, Deserialize, Validate, Debug, Clone)]
pub struct NvCreateResponse {
#[serde(flatten)]
pub inner: dynamo_async_openai::types::responses::CreateResponse,
#[serde(skip_serializing_if = "Option::is_none")]
pub nvext: Option<NvExt>,
}
#[derive(ToSchema, Serialize, Deserialize, Validate, Debug, Clone)]
pub struct NvResponse {
#[serde(flatten)]
pub inner: dynamo_async_openai::types::responses::Response,
#[serde(skip_serializing_if = "Option::is_none")]
pub nvext: Option<serde_json::Value>,
}
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 convert_image_detail(
detail: &dynamo_async_openai::types::responses::ImageDetail,
) -> ChatImageDetail {
match detail {
dynamo_async_openai::types::responses::ImageDetail::Auto => ChatImageDetail::Auto,
dynamo_async_openai::types::responses::ImageDetail::Low => ChatImageDetail::Low,
dynamo_async_openai::types::responses::ImageDetail::High => ChatImageDetail::High,
}
}
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(),
},
));
}
InputContent::InputImage(img) => {
let url_str = img.image_url.as_deref().unwrap_or_default();
let url = url::Url::parse(url_str)
.map_err(|e| anyhow::anyhow!("Invalid image URL '{}': {}", url_str, e))?;
chat_parts.push(ChatCompletionRequestUserMessageContentPart::ImageUrl(
ChatCompletionRequestMessageContentPartImage {
image_url: ImageUrl {
url,
detail: Some(convert_image_detail(&img.detail)),
uuid: None,
},
},
));
}
InputContent::InputVideo(vid) => {
let url = url::Url::parse(&vid.video)
.map_err(|e| anyhow::anyhow!("Invalid video URL '{}': {}", vid.video, e))?;
chat_parts.push(ChatCompletionRequestUserMessageContentPart::VideoUrl(
ChatCompletionRequestMessageContentPartVideo {
video_url: VideoUrl {
url,
detail: None,
uuid: None,
},
},
));
}
InputContent::InputAudio(_) => {
return Err(anyhow::anyhow!("Audio input content is not yet supported"));
}
InputContent::InputFile(_) => {
return Err(anyhow::anyhow!("File input content is not yet supported"));
}
InputContent::OutputText(t) => {
chat_parts.push(ChatCompletionRequestUserMessageContentPart::Text(
ChatCompletionRequestMessageContentPartText {
text: t.text.clone(),
},
));
}
InputContent::Refusal(r) => {
chat_parts.push(ChatCompletionRequestUserMessageContentPart::Text(
ChatCompletionRequestMessageContentPartText {
text: r.refusal.clone(),
},
));
}
}
}
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()),
InputContent::OutputText(t) => Some(t.text.as_str()),
InputContent::Refusal(r) => Some(r.refusal.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("")
}
fn convert_input_items_to_messages(
items: &[InputItem],
) -> Result<Vec<ChatCompletionRequestMessage>, anyhow::Error> {
let mut messages = Vec::with_capacity(items.len());
for item in items {
match item {
InputItem::Item(inner_item) => match inner_item {
Item::Message(msg_item) => match msg_item {
MessageItem::Input(msg) => {
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,
},
)
}
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()
.filter_map(|c| match c {
OutputMessageContent::OutputText(t) => Some(t.text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("");
messages.push(ChatCompletionRequestMessage::Assistant(
ChatCompletionRequestAssistantMessage {
content: Some(ChatCompletionRequestAssistantMessageContent::Text(
text,
)),
reasoning_content: None,
refusal: None,
name: None,
audio: None,
tool_calls: None,
#[allow(deprecated)]
function_call: None,
},
));
}
},
Item::FunctionCall(fc) => {
messages.push(ChatCompletionRequestMessage::Assistant(
ChatCompletionRequestAssistantMessage {
content: None,
reasoning_content: None,
refusal: None,
name: None,
audio: None,
tool_calls: Some(vec![ChatCompletionMessageToolCall {
id: fc.call_id.clone(),
r#type: ChatCompletionToolType::Function,
function: dynamo_async_openai::types::FunctionCall {
name: fc.name.clone(),
arguments: fc.arguments.clone(),
},
}]),
#[allow(deprecated)]
function_call: None,
},
));
}
Item::FunctionCallOutput(fco) => {
let output_text = match &fco.output {
FunctionCallOutput::Text(text) => text.clone(),
FunctionCallOutput::Content(parts) => convert_input_content_to_text(parts),
};
messages.push(ChatCompletionRequestMessage::Tool(
ChatCompletionRequestToolMessage {
content: ChatCompletionRequestToolMessageContent::Text(output_text),
tool_call_id: fco.call_id.clone(),
},
));
}
other => {
tracing::debug!(
"Skipping unsupported input item type during conversion: {:?}",
std::mem::discriminant(other)
);
}
},
InputItem::EasyMessage(easy) => {
let content_text = match &easy.content {
dynamo_async_openai::types::responses::EasyInputContent::Text(text) => {
text.clone()
}
dynamo_async_openai::types::responses::EasyInputContent::ContentList(parts) => {
convert_input_content_to_text(parts)
}
};
let chat_msg = match easy.role {
ResponseRole::System | ResponseRole::Developer => {
ChatCompletionRequestMessage::System(ChatCompletionRequestSystemMessage {
content: ChatCompletionRequestSystemMessageContent::Text(content_text),
name: None,
})
}
ResponseRole::User => {
ChatCompletionRequestMessage::User(ChatCompletionRequestUserMessage {
content: ChatCompletionRequestUserMessageContent::Text(content_text),
name: None,
})
}
ResponseRole::Assistant => ChatCompletionRequestMessage::Assistant(
ChatCompletionRequestAssistantMessage {
content: Some(ChatCompletionRequestAssistantMessageContent::Text(
content_text,
)),
reasoning_content: None,
refusal: None,
name: None,
audio: None,
tool_calls: None,
#[allow(deprecated)]
function_call: None,
},
),
};
messages.push(chat_msg);
}
InputItem::ItemReference(_) => {
}
}
}
Ok(messages)
}
fn convert_tools(tools: &[Tool]) -> Vec<ChatCompletionTool> {
tools
.iter()
.filter_map(|tool| match tool {
Tool::Function(f) => Some(ChatCompletionTool {
r#type: ChatCompletionToolType::Function,
function: FunctionObject {
name: f.name.clone(),
description: f.description.clone(),
parameters: f.parameters.clone(),
strict: f.strict,
},
}),
_ => None, })
.collect()
}
fn convert_tool_choice(tc: &ToolChoiceParam) -> ChatCompletionToolChoiceOption {
match tc {
ToolChoiceParam::Mode(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: f.name.clone(),
},
})
}
ToolChoiceParam::Hosted(_) => {
ChatCompletionToolChoiceOption::Auto
}
_ => {
ChatCompletionToolChoiceOption::Auto
}
}
}
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: &ServiceTier) -> ChatServiceTier {
match tier {
ServiceTier::Auto => ChatServiceTier::Auto,
ServiceTier::Default => ChatServiceTier::Default,
ServiceTier::Flex => ChatServiceTier::Flex,
ServiceTier::Scale => ChatServiceTier::Scale,
ServiceTier::Priority => ChatServiceTier::Priority,
}
}
impl TryFrom<NvCreateResponse> for NvCreateChatCompletionRequest {
type Error = anyhow::Error;
fn try_from(resp: NvCreateResponse) -> Result<Self, Self::Error> {
let mut messages = Vec::new();
if let Some(instructions) = &resp.inner.instructions {
messages.push(ChatCompletionRequestMessage::System(
ChatCompletionRequestSystemMessage {
content: ChatCompletionRequestSystemMessageContent::Text(instructions.clone()),
name: 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)?;
messages.extend(item_messages);
}
}
let top_logprobs = convert_top_logprobs(resp.inner.top_logprobs);
let tools = resp
.inner
.tools
.as_ref()
.map(|t| convert_tools(t))
.filter(|t: &Vec<_>| !t.is_empty());
let tool_choice = resp.inner.tool_choice.as_ref().map(convert_tool_choice);
let stream = resp.inner.stream.or(Some(true));
let reasoning_effort = resp.inner.reasoning.as_ref().and_then(|r| r.effort.clone());
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);
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,
top_logprobs,
metadata: resp.inner.metadata,
stream,
tools,
tool_choice,
reasoning_effort,
response_format,
service_tier,
..Default::default()
},
common: Default::default(),
nvext: resp.nvext,
chat_template_args: None,
media_io_kwargs: None,
unsupported_fields: Default::default(),
})
}
}
fn convert_top_logprobs(input: Option<u8>) -> Option<u8> {
input.map(|x| x.min(20))
}
fn parse_tool_call_text(text: &str) -> Vec<(String, String)> {
if !text.contains("<tool_call>") {
return Vec::new();
}
let mut results = Vec::new();
let mut search_start = 0;
while let Some(start) = text[search_start..].find("<tool_call>") {
let abs_start = search_start + start + "<tool_call>".len();
if let Some(end) = text[abs_start..].find("</tool_call>") {
let block = text[abs_start..abs_start + end].trim();
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(block) {
let name = parsed
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let arguments = if let Some(args) = parsed.get("arguments") {
if args.is_string() {
args.as_str().unwrap_or("{}").to_string()
} else {
serde_json::to_string(args).unwrap_or_else(|_| "{}".to_string())
}
} else {
"{}".to_string()
};
if !name.is_empty() {
results.push((name, arguments));
}
}
search_start = abs_start + end + "</tool_call>".len();
} else {
break;
}
}
results
}
fn strip_tool_call_text(text: &str) -> std::borrow::Cow<'_, str> {
let has_tool = text.contains("<tool_call>");
let has_think = text.contains("<think>");
if !has_tool && !has_think {
return std::borrow::Cow::Borrowed(text);
}
fn strip_tag(input: &mut String, open: &str, close: &str) {
while let Some(start) = input.find(open) {
if let Some(end_offset) = input[start..].find(close) {
input.replace_range(start..start + end_offset + close.len(), "");
} else {
input.truncate(start);
break;
}
}
}
let mut result = text.to_string();
if has_tool {
strip_tag(&mut result, "<tool_call>", "</tool_call>");
}
if has_think {
strip_tag(&mut result, "<think>", "</think>");
}
std::borrow::Cow::Owned(result)
}
#[derive(Clone, Debug, Default)]
pub struct ResponseParams {
pub model: Option<String>,
pub temperature: Option<f32>,
pub top_p: Option<f32>,
pub max_output_tokens: Option<u32>,
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<ServiceTier>,
pub include: Option<Vec<IncludeEnum>>,
pub truncation: Option<Truncation>,
}
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)
}
other => other,
})
.collect()
}
fn make_text_message(id: String, text: String) -> OutputItem {
OutputItem::Message(OutputMessage {
id: Some(id),
role: AssistantRole::Assistant,
status: Some(OutputStatus::Completed),
content: vec![OutputMessageContent::OutputText(OutputTextContent {
text,
annotations: vec![],
logprobs: Some(vec![]),
})],
})
}
fn make_function_call(name: String, arguments: String) -> OutputItem {
OutputItem::FunctionCall(FunctionToolCall {
arguments,
call_id: format!("call_{}", Uuid::new_v4().simple()),
name,
id: Some(format!("fc_{}", Uuid::new_v4().simple())),
status: Some(OutputStatus::Completed),
})
}
pub fn chat_completion_to_response(
nv_resp: NvCreateChatCompletionResponse,
params: &ResponseParams,
) -> Result<NvResponse, anyhow::Error> {
let chat_resp = nv_resp;
let nvext = chat_resp.nvext.clone();
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();
if let Some(choice) = choice {
if let Some(tool_calls) = choice.message.tool_calls {
for tc in &tool_calls {
output.push(OutputItem::FunctionCall(FunctionToolCall {
arguments: tc.function.arguments.clone(),
call_id: tc.id.clone(),
name: tc.function.name.clone(),
id: Some(format!("fc_{}", Uuid::new_v4().simple())),
status: Some(OutputStatus::Completed),
}));
}
}
if let Some(reasoning_text) = choice.message.reasoning_content
&& !reasoning_text.is_empty()
{
output.push(OutputItem::Reasoning(ReasoningItem {
id: format!("rs_{}", Uuid::new_v4().simple()),
summary: vec![SummaryPart::SummaryText(Summary {
text: reasoning_text,
})],
content: None,
encrypted_content: None,
status: Some(OutputStatus::Completed),
}));
}
let content_text = match choice.message.content {
Some(dynamo_async_openai::types::ChatCompletionMessageContent::Text(text)) => {
Some(text)
}
Some(dynamo_async_openai::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()
{
let parsed_calls = parse_tool_call_text(&content_text);
if !parsed_calls.is_empty() {
for (name, arguments) in parsed_calls {
output.push(make_function_call(name, arguments));
}
let remaining = strip_tool_call_text(&content_text);
if !remaining.trim().is_empty() {
output.push(make_text_message(
message_id.clone(),
remaining.into_owned(),
));
}
} else {
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));
if !keep_logprobs {
for item in &mut output {
if let OutputItem::Message(msg) = item {
for content in &mut msg.content {
if let OutputMessageContent::OutputText(text) = content {
text.logprobs = None;
}
}
}
}
}
let created_at = chat_resp.created as u64;
let response = Response {
id: response_id,
object: "response".to_string(),
created_at,
completed_at: Some(created_at),
model: if chat_resp.model == "unknown" {
params.model.clone().unwrap_or(chat_resp.model)
} else {
chat_resp.model
},
status: Status::Completed,
output,
background: Some(false),
frequency_penalty: Some(0.0),
metadata: Some(serde_json::Value::Object(Default::default())),
parallel_tool_calls: Some(true),
presence_penalty: Some(0.0),
store: params.store.or(Some(false)),
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::Mode(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: None,
instructions: params.instructions.clone().map(Instructions::Text),
max_output_tokens: params.max_output_tokens,
max_tool_calls: None,
previous_response_id: None,
prompt: None,
prompt_cache_key: None,
prompt_cache_retention: None,
reasoning: params.reasoning.clone(),
safety_identifier: None,
service_tier: Some(params.service_tier.unwrap_or(ServiceTier::Auto)),
top_logprobs: Some(0),
usage: chat_resp.usage.map(|u| ResponseUsage {
input_tokens: u.prompt_tokens,
input_tokens_details: InputTokenDetails {
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,
})
}
#[cfg(test)]
mod tests {
use dynamo_async_openai::types::responses::{
CreateResponse, FunctionCallOutput, FunctionCallOutputItemParam, FunctionTool,
FunctionToolCall, ImageDetail, InputContent, InputImageContent, InputItem, InputMessage,
InputParam, InputRole, InputTextContent, Item, MessageItem, Tool,
};
use dynamo_async_openai::types::{
ChatCompletionRequestMessage, ChatCompletionRequestUserMessageContent,
};
use super::*;
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()
}),
}
}
#[test]
fn test_annotations_trait_behavior() {
let req = make_response_with_input("hello");
assert_eq!(
req.annotations(),
Some(vec!["debug".to_string(), "trace".to_string()])
);
assert!(req.has_annotation("debug"));
assert!(req.has_annotation("trace"));
assert!(!req.has_annotation("missing"));
}
#[test]
fn test_openai_sampling_trait_behavior() {
let req = make_response_with_input("hello");
assert_eq!(req.get_temperature(), Some(0.5));
assert_eq!(req.get_top_p(), Some(0.9));
assert_eq!(req.get_frequency_penalty(), None);
assert_eq!(req.get_presence_penalty(), None);
}
#[test]
fn test_openai_stop_conditions_trait_behavior() {
let req = make_response_with_input("hello");
assert_eq!(req.get_max_tokens(), Some(1024));
assert_eq!(req.get_min_tokens(), None);
assert_eq!(req.get_stop(), None);
}
#[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 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,
};
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_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 {
text: "Be concise.".into(),
})],
role: InputRole::System,
status: None,
}))),
InputItem::Item(Item::Message(MessageItem::Input(InputMessage {
content: vec![InputContent::InputText(InputTextContent {
text: "What is 2+2?".into(),
})],
role: InputRole::User,
status: None,
}))),
InputItem::Item(Item::Message(MessageItem::Output(OutputMessage {
id: Some("msg_1".into()),
role: AssistantRole::Assistant,
status: Some(OutputStatus::Completed),
content: vec![OutputMessageContent::OutputText(OutputTextContent {
text: "4".into(),
annotations: vec![],
logprobs: None,
})],
}))),
InputItem::Item(Item::Message(MessageItem::Input(InputMessage {
content: vec![InputContent::InputText(InputTextContent {
text: "And 3+3?".into(),
})],
role: InputRole::User,
status: None,
}))),
]),
model: Some("test-model".into()),
..Default::default()
},
nvext: None,
};
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_image() {
let req = NvCreateResponse {
inner: CreateResponse {
input: InputParam::Items(vec![InputItem::Item(Item::Message(MessageItem::Input(
InputMessage {
content: vec![
InputContent::InputText(InputTextContent {
text: "What is in this image?".into(),
}),
InputContent::InputImage(InputImageContent {
detail: ImageDetail::Auto,
file_id: None,
image_url: Some("https://example.com/cat.jpg".into()),
}),
],
role: InputRole::User,
status: None,
},
)))]),
model: Some("test-model".into()),
..Default::default()
},
nvext: None,
};
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_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 {
text: "What's the weather?".into(),
})],
role: InputRole::User,
status: None,
}))),
InputItem::Item(Item::FunctionCall(FunctionToolCall {
arguments: r#"{"location":"SF"}"#.into(),
call_id: "call_123".into(),
name: "get_weather".into(),
id: None,
status: 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,
};
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(_)
));
assert!(matches!(messages[2], ChatCompletionRequestMessage::Tool(_)));
}
#[test]
fn test_tools_conversion() {
let req = NvCreateResponse {
inner: CreateResponse {
input: InputParam::Text("hello".into()),
model: Some("test-model".into()),
tools: Some(vec![Tool::Function(FunctionTool {
name: "get_weather".into(),
parameters: Some(serde_json::json!({
"type": "object",
"properties": {
"location": {"type": "string"}
},
"required": ["location"]
})),
strict: Some(true),
description: Some("Get weather info".into()),
})]),
..Default::default()
},
nvext: None,
};
let chat_req: NvCreateChatCompletionRequest = req.try_into().unwrap();
assert!(chat_req.inner.tools.is_some());
let tools = chat_req.inner.tools.unwrap();
assert_eq!(tools.len(), 1);
assert_eq!(tools[0].function.name, "get_weather");
}
#[allow(deprecated)]
#[test]
fn test_into_nvresponse_from_chat_response() {
let now = 1_726_000_000;
let chat_resp = NvCreateChatCompletionResponse {
id: "chatcmpl-xyz".into(),
choices: vec![dynamo_async_openai::types::ChatChoice {
index: 0,
message: dynamo_async_openai::types::ChatCompletionResponseMessage {
content: Some(
dynamo_async_openai::types::ChatCompletionMessageContent::Text(
"This is a reply".to_string(),
),
),
refusal: None,
tool_calls: None,
role: dynamo_async_openai::types::Role::Assistant,
function_call: None,
audio: None,
reasoning_content: None,
},
finish_reason: None,
stop_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,
nvext: None,
};
let wrapped = chat_completion_to_response(chat_resp, &ResponseParams::default()).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 {
id: "chatcmpl-xyz".into(),
choices: vec![dynamo_async_openai::types::ChatChoice {
index: 0,
message: dynamo_async_openai::types::ChatCompletionResponseMessage {
content: None,
refusal: None,
tool_calls: Some(vec![ChatCompletionMessageToolCall {
id: "call_abc".into(),
r#type: ChatCompletionToolType::Function,
function: dynamo_async_openai::types::FunctionCall {
name: "get_weather".into(),
arguments: r#"{"location":"SF"}"#.into(),
},
}]),
role: dynamo_async_openai::types::Role::Assistant,
function_call: None,
audio: None,
reasoning_content: None,
},
finish_reason: None,
stop_reason: None,
logprobs: None,
}],
created: now,
model: "test-model".into(),
service_tier: None,
system_fingerprint: None,
object: "chat.completion".to_string(),
usage: None,
nvext: None,
};
let wrapped = chat_completion_to_response(chat_resp, &ResponseParams::default()).unwrap();
assert_eq!(wrapped.inner.output.len(), 1);
match &wrapped.inner.output[0] {
OutputItem::FunctionCall(fc) => {
assert_eq!(fc.call_id, "call_abc");
assert_eq!(fc.name, "get_weather");
}
_ => panic!("Expected FunctionCall output"),
}
}
#[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_parse_tool_call_text() {
let text = r#"<think>
Let me check the weather.
</think>
<tool_call>
{"name": "get_weather", "arguments": {"location": "San Francisco"}}
</tool_call>"#;
let calls = parse_tool_call_text(text);
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].0, "get_weather");
let args: serde_json::Value = serde_json::from_str(&calls[0].1).unwrap();
assert_eq!(args["location"], "San Francisco");
}
#[test]
fn test_parse_tool_call_text_multiple() {
let text = r#"<tool_call>
{"name": "func_a", "arguments": {"x": 1}}
</tool_call>
<tool_call>
{"name": "func_b", "arguments": {"y": 2}}
</tool_call>"#;
let calls = parse_tool_call_text(text);
assert_eq!(calls.len(), 2);
assert_eq!(calls[0].0, "func_a");
assert_eq!(calls[1].0, "func_b");
}
#[test]
fn test_parse_tool_call_text_no_calls() {
let text = "Just a regular message with no tool calls.";
let calls = parse_tool_call_text(text);
assert!(calls.is_empty());
}
#[test]
fn test_strip_tool_call_text() {
let text = r#"<think>
thinking
</think>
<tool_call>
{"name": "f", "arguments": {}}
</tool_call>"#;
let stripped = strip_tool_call_text(text);
assert!(!stripped.contains("<tool_call>"));
assert!(!stripped.contains("<think>"));
}
#[test]
fn test_reasoning_effort_mapped_to_chat_completion() {
use dynamo_async_openai::types::ReasoningEffort;
use dynamo_async_openai::types::responses::Reasoning;
let mut req = make_response_with_input("think hard");
req.inner.reasoning = Some(Reasoning {
effort: Some(ReasoningEffort::Medium),
..Default::default()
});
let chat: NvCreateChatCompletionRequest = req.try_into().unwrap();
assert_eq!(chat.inner.reasoning_effort, Some(ReasoningEffort::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_async_openai::types::ResponseFormat;
use dynamo_async_openai::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_async_openai::types::responses::{
ResponseTextParam, TextResponseFormatConfiguration,
};
use dynamo_async_openai::types::{ResponseFormat, ResponseFormatJsonSchema};
let schema = ResponseFormatJsonSchema {
name: "city".into(),
description: None,
schema: Some(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_async_openai::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_async_openai::types::ServiceTier as ChatServiceTier;
use dynamo_async_openai::types::responses::ServiceTier as RespServiceTier;
let mut req = make_response_with_input("priority");
req.inner.service_tier = Some(RespServiceTier::Priority);
let chat: NvCreateChatCompletionRequest = req.try_into().unwrap();
assert_eq!(chat.inner.service_tier, Some(ChatServiceTier::Priority));
}
#[test]
fn test_response_echoes_reasoning() {
use dynamo_async_openai::types::ReasoningEffort;
use dynamo_async_openai::types::responses::Reasoning;
let params = ResponseParams {
reasoning: Some(Reasoning {
effort: Some(ReasoningEffort::High),
..Default::default()
}),
..Default::default()
};
let chat_resp = NvCreateChatCompletionResponse {
choices: vec![],
created: 0,
id: "test".into(),
model: "m".into(),
service_tier: None,
system_fingerprint: None,
object: "chat.completion".into(),
usage: None,
nvext: None,
};
let resp = chat_completion_to_response(chat_resp, ¶ms).unwrap();
let reasoning = resp.inner.reasoning.unwrap();
assert_eq!(reasoning.effort, Some(ReasoningEffort::High));
}
#[test]
fn test_response_echoes_text_format() {
use dynamo_async_openai::types::responses::{
ResponseTextParam, TextResponseFormatConfiguration,
};
let params = ResponseParams {
text: Some(ResponseTextParam {
format: TextResponseFormatConfiguration::JsonObject,
verbosity: None,
}),
..Default::default()
};
let chat_resp = NvCreateChatCompletionResponse {
choices: vec![],
created: 0,
id: "test".into(),
model: "m".into(),
service_tier: None,
system_fingerprint: None,
object: "chat.completion".into(),
usage: None,
nvext: None,
};
let resp = chat_completion_to_response(chat_resp, ¶ms).unwrap();
let text = resp.inner.text.unwrap();
assert_eq!(text.format, TextResponseFormatConfiguration::JsonObject);
}
#[test]
fn test_response_echoes_service_tier() {
use dynamo_async_openai::types::responses::ServiceTier;
let params = ResponseParams {
service_tier: Some(ServiceTier::Flex),
..Default::default()
};
let chat_resp = NvCreateChatCompletionResponse {
choices: vec![],
created: 0,
id: "test".into(),
model: "m".into(),
service_tier: None,
system_fingerprint: None,
object: "chat.completion".into(),
usage: None,
nvext: None,
};
let resp = chat_completion_to_response(chat_resp, ¶ms).unwrap();
assert_eq!(resp.inner.service_tier, Some(ServiceTier::Flex));
}
#[test]
fn test_output_message_deserializes_without_id_and_status() {
use dynamo_async_openai::types::responses::{InputItem, Item, MessageItem};
let json = serde_json::json!({
"role": "assistant",
"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.role, AssistantRole::Assistant);
assert_eq!(msg.content.len(), 1);
assert!(msg.id.is_none());
assert_eq!(msg.status, None);
}
other => panic!("Expected Item::Message(Output), got {:?}", other),
}
}
#[test]
fn test_output_message_with_id_and_status_still_works() {
use dynamo_async_openai::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_async_openai::types::{
ChatChoice, ChatCompletionMessageContent, ChatCompletionResponseMessage, FinishReason,
};
NvCreateChatCompletionResponse {
choices: vec![ChatChoice {
index: 0,
#[allow(deprecated)]
message: ChatCompletionResponseMessage {
content: Some(ChatCompletionMessageContent::Text(text.into())),
role: dynamo_async_openai::types::Role::Assistant,
tool_calls: None,
refusal: None,
reasoning_content: None,
function_call: None,
audio: None,
},
finish_reason: Some(FinishReason::Stop),
stop_reason: None,
logprobs: None,
}],
created: 0,
id: "test".into(),
model: "m".into(),
service_tier: None,
system_fingerprint: None,
object: "chat.completion".into(),
usage: None,
nvext: None,
}
}
#[test]
fn test_include_logprobs_stripped_by_default() {
let chat_resp = make_chat_resp_with_text("hello");
let params = ResponseParams::default();
let resp = chat_completion_to_response(chat_resp, ¶ms).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!(
t.logprobs.is_none(),
"logprobs should be stripped by default"
);
}
}
}
}
}
#[test]
fn test_include_logprobs_kept_when_requested() {
use dynamo_async_openai::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).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_async_openai::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).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).unwrap();
assert_eq!(resp.inner.truncation, Some(Truncation::Disabled));
}
}