use serde_json::{json, Map, Value};
use crate::schema::openai::{ResponsesInput, ResponsesItem, ResponsesRequest};
use crate::translator::{common, openai};
use crate::{
ApiBridgeError, ContentBlock, Result, Role, UniversalItem, UniversalRequest, WireProtocol,
};
pub(super) fn decode(raw: Value) -> Result<UniversalRequest> {
let source_raw = raw.clone();
let request: ResponsesRequest = serde_json::from_value(raw)
.map_err(|error| ApiBridgeError::invalid_request(error.to_string()))?;
let mut universal = UniversalRequest {
model: request.model,
instructions: openai::openai_content_to_blocks(request.instructions.as_ref()),
tools: request
.tools
.iter()
.filter_map(openai::openai_tool_from_value)
.collect(),
server_tools: request
.tools
.iter()
.filter_map(openai::openai_server_tool_from_value)
.collect(),
tool_choice: openai::tool_choice_from_openai_responses_value(request.tool_choice.as_ref()),
stream: request.stream.unwrap_or(false),
generation: openai::generation_from_openai(
request.temperature,
request.top_p,
request.max_output_tokens,
),
reasoning: openai::reasoning_from_openai(request.reasoning),
source: Some(common::source(WireProtocol::OpenAiResponses, source_raw)),
..UniversalRequest::default()
};
match request.input {
Some(ResponsesInput::Text(text)) => universal.input.push(UniversalItem::Message {
role: Role::User,
id: None,
content: vec![ContentBlock::Text { text }],
extensions: common::empty_extensions(),
}),
Some(ResponsesInput::Items(items)) => {
universal
.input
.extend(items.into_iter().map(item_to_universal));
}
Some(ResponsesInput::Raw(raw)) => universal.input.push(UniversalItem::Unknown { raw }),
Some(ResponsesInput::Null) | None => {}
}
Ok(universal)
}
pub(super) fn encode(request: &UniversalRequest) -> Result<Value> {
let mut body = Map::new();
if let Some(model) = &request.model {
body.insert("model".to_string(), Value::String(model.clone()));
}
if request.stream {
body.insert("stream".to_string(), Value::Bool(true));
}
if let Some(temperature) = request.generation.temperature {
body.insert("temperature".to_string(), json!(temperature));
}
if let Some(top_p) = request.generation.top_p {
body.insert("top_p".to_string(), json!(top_p));
}
if let Some(max_output_tokens) = request.generation.max_output_tokens {
body.insert("max_output_tokens".to_string(), json!(max_output_tokens));
}
if let Some(reasoning) = &request.reasoning {
let mut value = Map::new();
if let Some(effort) = openai::openai_reasoning_effort(reasoning.effort.as_deref()) {
value.insert("effort".to_string(), Value::String(effort.to_string()));
}
if !value.is_empty() {
body.insert("reasoning".to_string(), Value::Object(value));
}
}
if !request.instructions.is_empty() {
if let Some(instructions) = openai::blocks_to_plain_text(&request.instructions) {
body.insert("instructions".to_string(), Value::String(instructions));
}
}
if !request.tools.is_empty() {
body.insert(
"tools".to_string(),
Value::Array(
request
.tools
.iter()
.map(openai::tool_to_openai_responses)
.collect(),
),
);
}
if let Some(tool_choice) = &request.tool_choice {
body.insert(
"tool_choice".to_string(),
openai::tool_choice_to_openai_responses(tool_choice),
);
}
let input: Vec<Value> = request
.input
.iter()
.map(universal_item_to_response_item)
.collect::<Result<Vec<_>>>()?;
if !input.is_empty() {
body.insert("input".to_string(), Value::Array(input));
}
Ok(Value::Object(body))
}
fn item_to_universal(item: ResponsesItem) -> UniversalItem {
match item.kind.as_deref() {
Some("message") | None if item.role.is_some() => UniversalItem::Message {
role: item
.role
.as_deref()
.and_then(common::role_from_wire)
.unwrap_or(Role::User),
id: item.id,
content: openai::openai_content_to_blocks(item.content.as_ref()),
extensions: common::empty_extensions(),
},
Some("function_call") => UniversalItem::ToolCall {
id: item.call_id.or(item.id).unwrap_or_default(),
name: item.name.unwrap_or_default(),
arguments: response_arguments_to_universal(item.arguments),
extensions: common::empty_extensions(),
},
Some("function_call_output") => UniversalItem::ToolResult {
tool_call_id: item.call_id.or(item.id).unwrap_or_default(),
content: value_to_blocks(item.output.as_ref()),
is_error: false,
extensions: common::empty_extensions(),
},
Some("reasoning") => UniversalItem::Reasoning {
id: item.id,
text: item
.content
.as_ref()
.and_then(|content| text_from_openai_content(Some(content))),
encrypted: None,
extensions: common::empty_extensions(),
},
_ => UniversalItem::Unknown {
raw: serde_json::to_value(item).unwrap_or(Value::Null),
},
}
}
fn universal_item_to_response_item(item: &UniversalItem) -> Result<Value> {
match item {
UniversalItem::Message {
role, id, content, ..
} => {
let mut object = Map::new();
object.insert("type".to_string(), Value::String("message".to_string()));
object.insert(
"role".to_string(),
Value::String(openai::role_to_openai(*role).to_string()),
);
if let Some(id) = id {
object.insert("id".to_string(), Value::String(id.clone()));
}
let direction = if *role == Role::Assistant {
openai::OpenAiResponsesContentDirection::Output
} else {
openai::OpenAiResponsesContentDirection::Input
};
if let Some(content) = openai::blocks_to_openai_responses_part_array(content, direction)
{
object.insert(
"content".to_string(),
serde_json::to_value(content)
.map_err(|error| ApiBridgeError::conversion(error.to_string()))?,
);
}
Ok(Value::Object(object))
}
UniversalItem::ToolCall {
id,
name,
arguments,
..
} => Ok(json!({
"type": "function_call",
"call_id": id,
"name": name,
"arguments": common::stringify_arguments(arguments)
})),
UniversalItem::ToolResult {
tool_call_id,
content,
..
} => Ok(json!({
"type": "function_call_output",
"call_id": tool_call_id,
"output": blocks_to_value(content)
})),
UniversalItem::Reasoning {
id,
text,
encrypted,
..
} => Ok(json!({
"type": "reasoning",
"id": id,
"content": text,
"encrypted": encrypted
})),
UniversalItem::Unknown { raw } => Ok(raw.clone()),
}
}
fn response_arguments_to_universal(arguments: Option<Value>) -> Value {
match arguments {
Some(Value::String(arguments)) => common::parse_arguments(Some(&arguments)),
Some(arguments) => arguments,
None => Value::Null,
}
}
fn text_from_openai_content(
content: Option<&crate::schema::openai::OpenAiContent>,
) -> Option<String> {
let text = openai::openai_content_to_blocks(content)
.into_iter()
.filter_map(|block| match block {
ContentBlock::Text { text } => Some(text),
ContentBlock::Reasoning { text, .. } => text,
_ => None,
})
.collect::<Vec<_>>()
.join("");
(!text.is_empty()).then_some(text)
}
fn value_to_blocks(value: Option<&Value>) -> Vec<ContentBlock> {
match value {
Some(Value::String(text)) => vec![ContentBlock::Text { text: text.clone() }],
Some(Value::Array(values)) => values
.iter()
.cloned()
.map(|raw| ContentBlock::Unknown { raw })
.collect(),
Some(value) => vec![ContentBlock::Unknown { raw: value.clone() }],
None => Vec::new(),
}
}
fn blocks_to_value(blocks: &[ContentBlock]) -> Value {
match blocks {
[ContentBlock::Text { text }] => Value::String(text.clone()),
blocks => Value::Array(
blocks
.iter()
.map(|block| serde_json::to_value(block).unwrap_or(Value::Null))
.collect(),
),
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use crate::{OpenAiResponsesTranslator, ServerToolKind, ToolChoice, WireTranslator};
#[test]
fn decodes_responses_server_web_search_separately_from_function_tools() {
let request = OpenAiResponsesTranslator
.decode_request(json!({
"model": "gpt-5.5",
"input": "Find recent news.",
"tools": [
{
"type": "web_search",
"search_context_size": "low",
"allowed_domains": ["example.com"]
},
{
"type": "function",
"name": "list_files",
"description": "List local files.",
"parameters": { "type": "object" }
}
],
"tool_choice": { "type": "web_search" }
}))
.expect("request decodes");
assert_eq!(request.tools.len(), 1);
assert_eq!(request.tools[0].name, "list_files");
assert_eq!(request.server_tools.len(), 1);
assert_eq!(request.server_tools[0].kind, ServerToolKind::WebSearch);
assert_eq!(request.server_tools[0].wire_type, "web_search");
assert_eq!(request.server_tools[0].config["search_context_size"], "low");
assert_eq!(
request.tool_choice,
Some(ToolChoice::ServerTool {
kind: ServerToolKind::WebSearch
})
);
}
}