use std::collections::HashSet;
use locode_protocol::{ContentBlock, ImageSource, ReasoningFormat, ResultChunk, Role, ToolSpec};
use serde_json::{Value, json};
use super::wire;
use crate::http::normalize_input_schema;
use crate::openai::{OpenAiModelConfig, SystemPlacement};
use crate::request::{ConversationRequest, ReasoningEffort};
#[must_use]
pub fn freeform_tool_names(tools: &[ToolSpec]) -> HashSet<String> {
tools
.iter()
.filter(|spec| {
matches!(
spec.input,
locode_protocol::ToolInputFormat::Freeform { .. }
)
})
.map(|spec| spec.name.clone())
.collect()
}
#[must_use]
pub fn freeform_fallback_parameters() -> Value {
json!({
"type": "object",
"properties": {"input": {"type": "string",
"description": "The entire raw text input."}},
"required": ["input"],
"additionalProperties": false
})
}
#[must_use]
#[allow(clippy::too_many_lines)] pub fn build_request(req: &ConversationRequest, cfg: &OpenAiModelConfig) -> wire::ResponsesRequest {
let freeform = freeform_tool_names(&req.tools);
let use_custom = cfg.custom_tools_supported;
let mut instructions_parts: Vec<String> = Vec::new();
let mut input: Vec<Value> = Vec::new();
let mut custom_call_ids: HashSet<String> = HashSet::new();
for message in &req.messages {
match message.role {
Role::System => match cfg.system_placement {
SystemPlacement::Instructions => {
for block in &message.content {
if let ContentBlock::Text { text } = block {
instructions_parts.push(text.clone());
}
}
}
SystemPlacement::InputMessage => {
push_text_message(&mut input, "system", &message.content);
}
},
Role::Developer => push_text_message(&mut input, "developer", &message.content),
Role::User => {
let mut parts: Vec<Value> = Vec::new();
for block in &message.content {
match block {
ContentBlock::Text { text } => {
parts.push(json!({"type": "input_text", "text": text}));
}
ContentBlock::Image { source } => {
parts.push(json!({"type": "input_image",
"image_url": image_url(source)}));
}
ContentBlock::ToolResult {
tool_use_id,
content,
..
} => {
flush_user_parts(&mut input, &mut parts);
let output = join_chunks(content);
let item_type = if custom_call_ids.contains(tool_use_id) {
"custom_tool_call_output"
} else {
"function_call_output"
};
input.push(json!({"type": item_type,
"call_id": tool_use_id, "output": output}));
}
_ => {}
}
}
flush_user_parts(&mut input, &mut parts);
}
Role::Assistant => {
let start = input.len();
for block in &message.content {
match block {
ContentBlock::Text { text } => {
input.push(json!({"type": "message", "role": "assistant",
"content": [{"type": "output_text", "text": text}]}));
}
ContentBlock::ToolUse {
id,
name,
input: args,
} => {
let is_freeform = freeform.contains(name);
if is_freeform && use_custom {
let raw = args
.as_str()
.map_or_else(|| args.to_string(), ToString::to_string);
custom_call_ids.insert(id.clone());
input.push(json!({"type": "custom_tool_call",
"call_id": id, "name": name, "input": raw}));
} else {
let arguments = if is_freeform {
let raw = args
.as_str()
.map_or_else(|| args.to_string(), ToString::to_string);
json!({"input": raw}).to_string()
} else {
args.to_string()
};
input.push(json!({"type": "function_call",
"call_id": id, "name": name,
"arguments": arguments}));
}
}
ContentBlock::Reasoning {
format: ReasoningFormat::OpenAiResponses,
payload: Some(payload),
..
} => {
let mut item = payload.clone();
if let Some(obj) = item.as_object_mut() {
obj.remove("status");
}
input.push(item);
}
_ => {}
}
}
drop_trailing_reasoning(&mut input, start);
}
}
}
let tools: Vec<Value> = req
.tools
.iter()
.map(|spec| tool_def(spec, use_custom))
.collect();
wire::ResponsesRequest {
model: cfg.model.clone(),
instructions: (!instructions_parts.is_empty()).then(|| instructions_parts.join("\n\n")),
input,
tools: (!tools.is_empty()).then_some(tools),
reasoning: reasoning_param(req, cfg),
store: false,
stream: false,
include: vec!["reasoning.encrypted_content".to_string()],
max_output_tokens: Some(
cfg.max_tokens_cap
.map_or(req.sampling_args.max_tokens, |cap| {
req.sampling_args.max_tokens.min(cap)
})
.max(16),
),
temperature: req.sampling_args.temperature,
top_p: req.sampling_args.top_p,
prompt_cache_key: cfg.prompt_cache_key.clone(),
provider: cfg.effective_provider_prefs(),
}
}
fn drop_trailing_reasoning(input: &mut Vec<Value>, start: usize) {
while input.len() > start
&& input
.last()
.and_then(|item| item.get("type"))
.and_then(Value::as_str)
== Some("reasoning")
{
input.pop();
}
}
fn push_text_message(input: &mut Vec<Value>, role: &str, content: &[ContentBlock]) {
let mut text = String::new();
for block in content {
if let ContentBlock::Text { text: t } = block {
if !text.is_empty() {
text.push('\n');
}
text.push_str(t);
}
}
if !text.is_empty() {
input.push(json!({"type": "message", "role": role,
"content": [{"type": "input_text", "text": text}]}));
}
}
fn flush_user_parts(input: &mut Vec<Value>, parts: &mut Vec<Value>) {
if !parts.is_empty() {
input.push(json!({"type": "message", "role": "user",
"content": std::mem::take(parts)}));
}
}
fn image_url(source: &ImageSource) -> String {
match source {
ImageSource::Base64 { media_type, data } => format!("data:{media_type};base64,{data}"),
ImageSource::Url { url } => url.clone(),
}
}
fn join_chunks(chunks: &[ResultChunk]) -> String {
let mut out = String::new();
for chunk in chunks {
if let ResultChunk::Text { text } = chunk {
if !out.is_empty() {
out.push('\n');
}
out.push_str(text);
}
}
out
}
fn tool_def(spec: &ToolSpec, use_custom: bool) -> Value {
match &spec.input {
locode_protocol::ToolInputFormat::JsonSchema { parameters } => {
json!({"type": "function", "name": spec.name,
"description": spec.description,
"parameters": normalize_input_schema(parameters.clone()),
"strict": false})
}
locode_protocol::ToolInputFormat::Freeform { syntax, definition } => {
if use_custom {
let syntax = match syntax {
locode_protocol::GrammarSyntax::Lark => "lark",
locode_protocol::GrammarSyntax::Regex => "regex",
};
json!({"type": "custom", "name": spec.name,
"description": spec.description,
"format": {"type": "grammar", "syntax": syntax,
"definition": definition}})
} else {
json!({"type": "function", "name": spec.name,
"description": spec.description,
"parameters": freeform_fallback_parameters(),
"strict": false})
}
}
}
}
fn reasoning_param(req: &ConversationRequest, cfg: &OpenAiModelConfig) -> Option<Value> {
let effort = req
.sampling_args
.reasoning_effort
.as_ref()
.map(|e| match e {
ReasoningEffort::None => "none".to_string(),
ReasoningEffort::Minimal => "minimal".to_string(),
ReasoningEffort::Low => "low".to_string(),
ReasoningEffort::Medium => "medium".to_string(),
ReasoningEffort::High => "high".to_string(),
ReasoningEffort::XHigh => "xhigh".to_string(),
ReasoningEffort::Other(s) => s.clone(),
});
let effort = effort?;
let mut obj = serde_json::Map::new();
obj.insert("effort".to_string(), Value::String(effort));
if let Some(s) = cfg.reasoning_summary.as_ref() {
obj.insert("summary".to_string(), Value::String(s.clone()));
}
Some(Value::Object(obj))
}