use crate::core::models::openai::messages::{
ChatMessage, ContentPart, ImageUrl, MessageContent, MessageRole,
};
use crate::core::models::openai::requests::ChatCompletionRequest;
use crate::core::models::openai::responses_api::{
ResponseFunctionCall, ResponseInput, ResponseInputContent, ResponseInputContentPart,
ResponseInputItem, ResponseOutputContent, ResponseOutputItem, ResponseOutputMessage,
ResponseTool, ResponseUsage, ResponsesApiRequest, ResponsesApiResponse,
};
use crate::core::types::responses::FinishReason;
use crate::server::routes::ai::chat::handle_chat_completion_with_state;
use crate::server::routes::errors;
use crate::server::state::AppState;
use actix_web::{HttpRequest, HttpResponse, ResponseError, Result as ActixResult, web};
use tracing::{error, info};
pub async fn create_response(
state: web::Data<AppState>,
req: HttpRequest,
body: web::Json<ResponsesApiRequest>,
) -> ActixResult<HttpResponse> {
info!("Responses API request for model: {}", body.model);
let context = super::context::get_request_context(&req)?;
let request = body.into_inner();
if request.model.trim().is_empty() {
return Ok(errors::validation_error("model must not be empty"));
}
match &request.input {
ResponseInput::Text(t) if t.trim().is_empty() => {
return Ok(errors::validation_error("input text must not be empty"));
}
ResponseInput::Items(items) if items.is_empty() => {
return Ok(errors::validation_error("input array must not be empty"));
}
_ => {}
}
if request.background.unwrap_or(false) {
return Ok(errors::validation_error(
"background (async) execution is not supported; omit background or set it to false",
));
}
if request.previous_response_id.is_some() {
return Ok(errors::validation_error(
"previous_response_id (stateful chaining) is not supported; \
omit previous_response_id to make a fresh request",
));
}
let chat_request = match build_chat_request(&request) {
Ok(r) => r,
Err(e) => return Ok(errors::validation_error(&e)),
};
if request.stream.unwrap_or(false) {
super::responses_stream::handle_streaming_response(
state.get_ref(),
chat_request,
request,
context,
)
.await
} else {
handle_sync_response(state.get_ref(), chat_request, request, context).await
}
}
async fn handle_sync_response(
state: &AppState,
chat_request: ChatCompletionRequest,
original: ResponsesApiRequest,
context: crate::core::types::context::RequestContext,
) -> ActixResult<HttpResponse> {
match handle_chat_completion_with_state(state, chat_request, context).await {
Ok(chat_resp) => {
let resp = convert_to_responses_api(chat_resp, &original);
Ok(HttpResponse::Ok().json(resp))
}
Err(e) => {
error!("Responses API error: {}", e);
Ok(e.error_response())
}
}
}
pub(crate) fn build_chat_request(
req: &ResponsesApiRequest,
) -> Result<ChatCompletionRequest, String> {
let mut messages: Vec<ChatMessage> = Vec::new();
if let Some(instructions) = &req.instructions {
messages.push(ChatMessage {
role: MessageRole::System,
content: Some(MessageContent::Text(instructions.to_string())),
name: None,
function_call: None,
tool_calls: None,
tool_call_id: None,
audio: None,
});
}
match &req.input {
ResponseInput::Text(text) => {
messages.push(ChatMessage {
role: MessageRole::User,
content: Some(MessageContent::Text(text.clone())),
name: None,
function_call: None,
tool_calls: None,
tool_call_id: None,
audio: None,
});
}
ResponseInput::Items(items) => {
for item in items {
let ResponseInputItem::Message(msg) = item;
let role = parse_role(&msg.role)?;
let content = match &msg.content {
ResponseInputContent::Text(t) => MessageContent::Text(t.clone()),
ResponseInputContent::Parts(parts) => {
let mut content_parts: Vec<ContentPart> = Vec::new();
for p in parts {
match p {
ResponseInputContentPart::InputText { text }
| ResponseInputContentPart::OutputText { text } => {
content_parts.push(ContentPart::Text { text: text.clone() });
}
ResponseInputContentPart::InputImage { image_url, detail } => {
match image_url {
Some(url) => {
content_parts.push(ContentPart::ImageUrl {
image_url: ImageUrl {
url: url.clone(),
detail: detail.clone(),
},
});
}
None => {
return Err(
"input_image part is missing image_url".to_string()
);
}
}
}
}
}
if content_parts.len() == 1 {
if let ContentPart::Text { text } = &content_parts[0] {
MessageContent::Text(text.clone())
} else {
MessageContent::Parts(content_parts)
}
} else {
MessageContent::Parts(content_parts)
}
}
};
messages.push(ChatMessage {
role,
content: Some(content),
name: None,
function_call: None,
tool_calls: None,
tool_call_id: None,
audio: None,
});
}
}
}
if messages.is_empty() {
return Err("input must contain at least one message".to_string());
}
let mut tools: Vec<crate::core::models::openai::tools::Tool> = Vec::new();
if let Some(req_tools) = &req.tools {
for t in req_tools {
match t {
ResponseTool::Function(f) => {
tools.push(crate::core::models::openai::tools::Tool {
tool_type: "function".to_string(),
function: crate::core::models::openai::tools::Function {
name: f.function.name.clone(),
description: f.function.description.clone(),
parameters: f.function.parameters.clone(),
},
});
}
ResponseTool::WebSearch(_)
| ResponseTool::WebSearchPreview(_)
| ResponseTool::FileSearch(_)
| ResponseTool::CodeInterpreter(_)
| ResponseTool::ComputerUsePreview(_)
| ResponseTool::Mcp(_) => {
return Err(
"built-in tools (web_search, file_search, code_interpreter, mcp, \
computer_use) are not supported via the chat-completions proxy path"
.to_string(),
);
}
}
}
}
Ok(ChatCompletionRequest {
model: req.model.clone(),
messages,
temperature: req.temperature,
max_completion_tokens: req.max_output_tokens,
top_p: req.top_p,
stream: req.stream,
user: req.user.clone(),
tools: if tools.is_empty() { None } else { Some(tools) },
reasoning_effort: req.reasoning.as_ref().and_then(|r| r.effort.clone()),
store: req.store,
metadata: req.metadata.clone(),
..Default::default()
})
}
pub(crate) fn convert_to_responses_api(
chat: crate::core::models::openai::responses::ChatCompletionResponse,
original: &ResponsesApiRequest,
) -> ResponsesApiResponse {
let resp_id = format!("resp_{}", &chat.id);
let overall_status = chat
.choices
.first()
.and_then(|c| c.finish_reason.as_deref())
.map(|r| finish_reason_to_status(Some(r)))
.unwrap_or("completed");
let output: Vec<ResponseOutputItem> = chat
.choices
.into_iter()
.flat_map(|choice| {
let finish_status = finish_reason_to_status(choice.finish_reason.as_deref());
let mut items: Vec<ResponseOutputItem> = Vec::new();
let text_content: Vec<ResponseOutputContent> = match &choice.message.content {
Some(MessageContent::Text(t)) if !t.is_empty() => {
vec![ResponseOutputContent::OutputText {
text: t.clone(),
annotations: None,
logprobs: None,
}]
}
Some(MessageContent::Parts(parts)) => parts
.iter()
.filter_map(|part| {
if let ContentPart::Text { text } = part
&& !text.is_empty()
{
return Some(ResponseOutputContent::OutputText {
text: text.clone(),
annotations: None,
logprobs: None,
});
}
None
})
.collect(),
_ => vec![],
};
if !text_content.is_empty() {
items.push(ResponseOutputItem::Message(ResponseOutputMessage {
id: format!("msg_{}", uuid_v4_hex()),
role: "assistant".to_string(),
status: finish_status.to_string(),
content: text_content,
}));
}
if let Some(tool_calls) = choice.message.tool_calls {
for tc in tool_calls {
items.push(ResponseOutputItem::FunctionCall(ResponseFunctionCall {
id: format!("fc_{}", uuid_v4_hex()),
name: tc.function.name.clone(),
arguments: tc.function.arguments.clone(),
status: finish_status.to_string(),
call_id: Some(tc.id.clone()),
}));
}
}
if items.is_empty() {
items.push(ResponseOutputItem::Message(ResponseOutputMessage {
id: format!("msg_{}", uuid_v4_hex()),
role: "assistant".to_string(),
status: finish_status.to_string(),
content: vec![],
}));
}
items
})
.collect();
let usage = chat.usage.map(|u| ResponseUsage {
input_tokens: u.prompt_tokens,
output_tokens: u.completion_tokens,
total_tokens: u.total_tokens,
input_tokens_details: u.prompt_tokens_details.map(|d| {
crate::core::models::openai::responses_api::ResponseInputTokensDetails {
cached_tokens: d.cached_tokens.unwrap_or(0),
}
}),
output_tokens_details: u.completion_tokens_details.map(|d| {
crate::core::models::openai::responses_api::ResponseOutputTokensDetails {
reasoning_tokens: d.reasoning_tokens.unwrap_or(0),
}
}),
});
ResponsesApiResponse {
id: resp_id,
object: "response".to_string(),
created_at: chat.created as i64,
status: overall_status.to_string(),
model: chat.model,
output,
usage,
error: None,
previous_response_id: original.previous_response_id.clone(),
metadata: original.metadata.clone(),
}
}
pub(crate) fn finish_reason_to_status(reason: Option<&str>) -> &'static str {
match reason {
Some("length") => "incomplete",
Some("content_filter") => "failed",
_ => "completed",
}
}
pub(crate) fn finish_reason_enum_to_status(reason: Option<&FinishReason>) -> &'static str {
match reason {
Some(FinishReason::Length) => "incomplete",
Some(FinishReason::ContentFilter) => "failed",
_ => "completed",
}
}
pub(crate) fn parse_role(role: &str) -> Result<MessageRole, String> {
match role {
"user" => Ok(MessageRole::User),
"assistant" => Ok(MessageRole::Assistant),
"system" => Ok(MessageRole::System),
"developer" => Ok(MessageRole::Developer),
other => Err(format!("unknown message role: {other}")),
}
}
pub(crate) fn uuid_v4_hex() -> String {
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos() as u64;
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
format!("{nanos:016x}{seq:08x}")
}
pub(crate) fn current_unix_ts() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::models::openai::responses_api::{ResponseInput, ResponsesApiRequest};
fn minimal_request(input: &str) -> ResponsesApiRequest {
ResponsesApiRequest {
model: "gpt-4o".to_string(),
input: ResponseInput::Text(input.to_string()),
instructions: None,
previous_response_id: None,
store: None,
tools: None,
stream: None,
background: None,
max_output_tokens: None,
temperature: None,
top_p: None,
user: None,
reasoning: None,
metadata: None,
truncation: None,
}
}
#[test]
fn test_text_input_becomes_user_message() {
let req = minimal_request("Hello");
let chat = build_chat_request(&req).unwrap();
assert_eq!(chat.model, "gpt-4o");
assert_eq!(chat.messages.len(), 1);
assert!(matches!(chat.messages[0].role, MessageRole::User));
}
#[test]
fn test_instructions_prepended_as_system() {
let mut req = minimal_request("Hi");
req.instructions = Some("Be brief".to_string());
let chat = build_chat_request(&req).unwrap();
assert_eq!(chat.messages.len(), 2);
assert!(matches!(chat.messages[0].role, MessageRole::System));
}
#[test]
fn test_temperature_forwarded() {
let mut req = minimal_request("test");
req.temperature = Some(0.5);
let chat = build_chat_request(&req).unwrap();
assert_eq!(chat.temperature, Some(0.5));
}
#[test]
fn test_max_output_tokens_maps_to_max_completion_tokens() {
let mut req = minimal_request("test");
req.max_output_tokens = Some(512);
let chat = build_chat_request(&req).unwrap();
assert_eq!(chat.max_completion_tokens, Some(512));
}
#[test]
fn test_reasoning_effort_forwarded() {
let mut req = minimal_request("test");
req.reasoning = Some(
crate::core::models::openai::responses_api::ReasoningParams {
effort: Some("high".to_string()),
summary: None,
},
);
let chat = build_chat_request(&req).unwrap();
assert_eq!(chat.reasoning_effort.as_deref(), Some("high"));
}
#[test]
fn test_parse_role_valid_values() {
assert!(matches!(parse_role("user").unwrap(), MessageRole::User));
assert!(matches!(
parse_role("assistant").unwrap(),
MessageRole::Assistant
));
assert!(matches!(parse_role("system").unwrap(), MessageRole::System));
}
#[test]
fn test_parse_role_invalid_returns_error() {
assert!(parse_role("unknown").is_err());
}
}