use crate::providers::{
ChatMessage, MessageRole, ProviderConversationItem, ProviderRequest, ProviderToolResult,
replay_trace::ReplayDropTrace,
};
#[cfg(test)]
use crate::tools::mvp_tool_definitions_json_with_subagents;
use serde_json::{Value, json};
use std::collections::HashSet;
#[cfg(test)]
use std::sync::OnceLock;
use crate::config::CustomReasoningProtocol;
pub(crate) const DEFAULT_CUSTOM_MAX_OUTPUT_TOKENS: u64 = 4096;
pub(crate) fn openai_compatible_chat_completions_body_with_protocol(
model: &str,
request: &ProviderRequest,
protocol: CustomReasoningProtocol,
max_output_tokens: Option<u64>,
) -> Value {
let mut body = openai_compatible_chat_completions_body(model, request);
apply_custom_reasoning(&mut body, request, protocol, max_output_tokens);
body
}
pub(crate) fn openai_compatible_responses_body_with_support(
supports_text_verbosity: bool,
model: &str,
request: &ProviderRequest,
protocol: CustomReasoningProtocol,
max_output_tokens: Option<u64>,
) -> Value {
let mut body = openai_compatible_responses_body(model, request);
if supports_text_verbosity && let Some(verbosity) = request.text_verbosity() {
body["text"] = json!({"verbosity": verbosity.as_api_str()});
}
apply_custom_reasoning(&mut body, request, protocol, max_output_tokens);
body
}
#[cfg(test)]
pub(crate) fn openai_compatible_responses_body_with_protocol(
model: &str,
request: &ProviderRequest,
protocol: CustomReasoningProtocol,
max_output_tokens: Option<u64>,
) -> Value {
openai_compatible_responses_body_with_support(
false,
model,
request,
protocol,
max_output_tokens,
)
}
fn apply_custom_reasoning(
body: &mut Value,
request: &ProviderRequest,
protocol: CustomReasoningProtocol,
max_output_tokens: Option<u64>,
) {
if protocol == CustomReasoningProtocol::GptLike {
return;
}
if !request.send_default_reasoning_summary() {
return;
}
body.as_object_mut()
.expect("custom provider body is object")
.remove("reasoning_effort");
body.as_object_mut()
.expect("custom provider body is object")
.remove("reasoning");
let Some(budget) = (match request.thinking_level {
crate::thinking::ThinkingLevel::High => Some(16_384),
crate::thinking::ThinkingLevel::Max => Some(32_768),
_ => None,
}) else {
return;
};
let ceiling = max_output_tokens.unwrap_or(DEFAULT_CUSTOM_MAX_OUTPUT_TOKENS);
if ceiling <= 1 {
return;
}
let budget = budget.min(ceiling - 1);
body["thinking"] = json!({"type": "enabled", "budget_tokens": budget});
body["max_tokens"] = json!(ceiling);
}
pub(crate) fn openai_compatible_chat_completions_body(
model: &str,
request: &ProviderRequest,
) -> Value {
let mut state = ChatBodyBuildState::default();
for item in request.conversation_items_iter() {
state.append_conversation_item(item);
}
state.trace_dropped_replay_items();
let mut body = json!({
"model": model,
"stream": request.stream,
"messages": state.messages,
});
if request.stream {
body["stream_options"] = json!({"include_usage": true});
}
if let Some(tool_definitions) = request.tool_definitions_json_if_enabled() {
body["tools"] = chat_completion_tools_from_definitions(tool_definitions);
body["tool_choice"] = json!("auto");
}
if request.send_default_reasoning_summary()
&& let Some(effort) = request.thinking_level.explicit_effort()
{
body["reasoning_effort"] = json!(effort);
}
body
}
pub(crate) fn openai_compatible_responses_body(model: &str, request: &ProviderRequest) -> Value {
let instructions = response_instructions(request);
let mut state = ResponseInputBuildState::default();
for item in request.conversation_items_iter() {
state.append_custom_conversation_item(item);
}
let mut body = json!({
"model": model,
"store": false,
"stream": request.stream,
"instructions": instructions,
"input": state.input,
"include": ["reasoning.encrypted_content"],
});
if let Some(prompt_cache_key) = request.prompt_cache_key() {
body["prompt_cache_key"] = json!(prompt_cache_key);
}
if let Some(tool_definitions) = request.tool_definitions_json_if_enabled() {
body["tools"] = tool_definitions;
body["tool_choice"] = json!("auto");
}
if request.send_default_reasoning_summary() {
let mut reasoning = json!({"summary": "auto"});
if let Some(effort) = request.thinking_level.explicit_effort() {
reasoning["effort"] = json!(effort);
}
body["reasoning"] = reasoning;
}
body
}
#[derive(Debug)]
struct ChatBodyBuildState {
messages: Vec<Value>,
seen_tool_calls: HashSet<String>,
seen_tool_results: HashSet<String>,
replay_drop_trace: ReplayDropTrace,
}
impl Default for ChatBodyBuildState {
fn default() -> Self {
Self {
messages: Vec::new(),
seen_tool_calls: HashSet::new(),
seen_tool_results: HashSet::new(),
replay_drop_trace: ReplayDropTrace::new("openai_chat_completions"),
}
}
}
impl ChatBodyBuildState {
fn append_conversation_item(&mut self, item: &ProviderConversationItem) {
match item {
ProviderConversationItem::Message(message) => {
self.messages.push(chat_completion_message_json(message))
}
ProviderConversationItem::ResponseItem(item) => self.append_response_item(item),
ProviderConversationItem::ToolResult(result) => {
if self.seen_tool_results.insert(result.call_id.clone()) {
self.messages.push(chat_tool_result_message(result));
}
}
ProviderConversationItem::LegacyReplayNote {
event_type,
content,
} => {
self.messages.push(json!({
"role": "user",
"content": ProviderConversationItem::legacy_note_text(event_type, content),
}));
}
}
}
fn append_response_item(&mut self, item: &Value) {
if item.get("type").and_then(Value::as_str) == Some("function_call") {
let Some(call_id) = item.get("call_id").and_then(Value::as_str) else {
self.drop_replay_item("function_call_missing_call_id");
return;
};
let Some(name) = item.get("name").and_then(Value::as_str) else {
self.drop_replay_item("function_call_missing_name");
return;
};
if !self.seen_tool_calls.insert(call_id.to_string()) {
return;
}
self.messages.push(json!({
"role": "assistant",
"content": " ",
"tool_calls": [{
"id": call_id,
"type": "function",
"function": {
"name": name,
"arguments": chat_tool_arguments_text(item.get("arguments").unwrap_or(&Value::Null)),
}
}],
}));
return;
}
if item.get("type").and_then(Value::as_str) == Some("function_call_output") {
let Some(call_id) = item.get("call_id").and_then(Value::as_str) else {
self.drop_replay_item("function_call_output_missing_call_id");
return;
};
if !self.seen_tool_results.insert(call_id.to_string()) {
return;
}
self.messages.push(json!({
"role": "tool",
"tool_call_id": call_id,
"content": chat_content_value(item.get("output")),
}));
return;
}
let Some(role) = item.get("role").and_then(Value::as_str) else {
self.drop_replay_item("message_missing_role");
return;
};
if !matches!(role, "system" | "user" | "assistant" | "tool") {
self.drop_replay_item("message_unsupported_role");
return;
}
let mut message = json!({
"role": role,
"content": chat_content_value(item.get("content")),
});
if role == "assistant" {
if let Some(tool_calls) = item.get("tool_calls") {
message["tool_calls"] = sanitize_chat_tool_calls(tool_calls);
record_chat_tool_call_ids(&mut self.seen_tool_calls, &message["tool_calls"]);
}
} else if role == "tool"
&& let Some(tool_call_id) = item.get("tool_call_id").and_then(Value::as_str)
{
message["tool_call_id"] = json!(tool_call_id);
self.seen_tool_results.insert(tool_call_id.to_string());
}
self.messages.push(message);
}
fn drop_replay_item(&mut self, reason: &str) {
self.replay_drop_trace.drop_item(reason);
}
fn trace_dropped_replay_items(&self) {
self.replay_drop_trace.trace_summary();
}
}
fn chat_completion_message_json(message: &ChatMessage) -> Value {
json!({
"role": message.role.as_api_str(),
"content": message.content,
})
}
#[cfg(test)]
static CHAT_COMPLETION_TOOLS_JSON: OnceLock<Value> = OnceLock::new();
#[cfg(test)]
pub(crate) fn chat_completion_tools_json(include_subagents: bool) -> Value {
if include_subagents {
CHAT_COMPLETION_TOOLS_JSON
.get_or_init(|| build_chat_completion_tools_json(true))
.clone()
} else {
build_chat_completion_tools_json(false)
}
}
#[cfg(test)]
pub(crate) fn build_chat_completion_tools_json(include_subagents: bool) -> Value {
chat_completion_tools_from_definitions(mvp_tool_definitions_json_with_subagents(
include_subagents,
))
}
fn chat_completion_tools_from_definitions(tool_definitions: Value) -> Value {
let tools = tool_definitions
.as_array()
.cloned()
.unwrap_or_default()
.into_iter()
.map(|tool| {
json!({
"type": "function",
"function": {
"name": tool.get("name").cloned().unwrap_or(Value::Null),
"description": tool.get("description").cloned().unwrap_or(Value::Null),
"parameters": tool.get("parameters").cloned().unwrap_or(Value::Null),
}
})
})
.collect::<Vec<_>>();
Value::Array(tools)
}
fn chat_tool_result_message(result: &ProviderToolResult) -> Value {
json!({
"role": "tool",
"tool_call_id": result.call_id,
"content": chat_content_value(Some(&Value::String(result.output.clone()))),
})
}
fn sanitize_chat_tool_calls(tool_calls: &Value) -> Value {
let calls = tool_calls
.as_array()
.into_iter()
.flatten()
.filter_map(|call| {
let id = call.get("id")?.as_str()?;
let function = call.get("function")?;
let name = function.get("name")?.as_str()?;
Some(json!({
"id": id,
"type": "function",
"function": {
"name": name,
"arguments": chat_tool_arguments_text(function.get("arguments").unwrap_or(&Value::Null)),
}
}))
})
.collect::<Vec<_>>();
Value::Array(calls)
}
fn record_chat_tool_call_ids(seen_tool_calls: &mut HashSet<String>, tool_calls: &Value) {
for call in tool_calls.as_array().into_iter().flatten() {
if let Some(id) = call.get("id").and_then(Value::as_str) {
seen_tool_calls.insert(id.to_string());
}
}
}
fn chat_tool_arguments_text(arguments: &Value) -> String {
match arguments {
Value::String(text) => text.clone(),
Value::Null => "{}".to_string(),
value => value.to_string(),
}
}
fn chat_content_value(content: Option<&Value>) -> Value {
match content {
Some(Value::String(text)) if !text.is_empty() => json!(text),
Some(Value::Array(parts)) if !parts.is_empty() => Value::Array(parts.clone()),
Some(Value::String(_)) | Some(Value::Null) | None => json!(" "),
Some(value) => value.clone(),
}
}
pub(crate) fn codex_responses_body(model: &str, request: &ProviderRequest) -> Value {
let instructions = response_instructions(request);
let mut state = ResponseInputBuildState::default();
for item in request.conversation_items_iter() {
state.append_codex_conversation_item(item);
}
let mut body = json!({
"model": model,
"store": false,
"stream": true,
"instructions": instructions,
"input": state.input,
"text": {"verbosity": request.text_verbosity().unwrap_or_default().as_api_str()},
"include": ["reasoning.encrypted_content"],
});
if let Some(prompt_cache_key) = request.prompt_cache_key() {
body["prompt_cache_key"] = json!(prompt_cache_key);
}
if let Some(tool_definitions) = request.tool_definitions_json_if_enabled() {
body["tools"] = tool_definitions;
body["tool_choice"] = json!("auto");
body["parallel_tool_calls"] = json!(true);
}
insert_reasoning(
&mut body,
request.thinking_level,
request.send_default_reasoning_summary(),
);
body
}
pub(crate) fn response_instructions(request: &ProviderRequest) -> String {
request
.conversation_items_iter()
.filter_map(|item| match item {
ProviderConversationItem::Message(message) if message.role == MessageRole::System => {
Some(message.content.as_str())
}
_ => None,
})
.collect::<Vec<_>>()
.join("\n\n")
}
#[derive(Debug, Default)]
struct ResponseInputBuildState {
input: Vec<Value>,
seen_tool_results: HashSet<String>,
}
impl ResponseInputBuildState {
fn append_codex_conversation_item(&mut self, item: &ProviderConversationItem) {
if let ProviderConversationItem::ResponseItem(item) = item
&& matches!(
item.get("type").and_then(Value::as_str),
Some("thinking" | "redacted_thinking")
)
{
return;
}
self.append_response_conversation_item(item, sanitize_codex_replayed_response_item);
}
fn append_custom_conversation_item(&mut self, item: &ProviderConversationItem) {
self.append_response_conversation_item(item, sanitize_custom_stateless_response_item);
}
fn append_response_conversation_item(
&mut self,
item: &ProviderConversationItem,
sanitize: fn(&Value) -> Value,
) {
match item {
ProviderConversationItem::Message(message) if message.role != MessageRole::System => {
self.input.push(message_json(message));
}
ProviderConversationItem::Message(_) => {}
ProviderConversationItem::ResponseItem(item) => {
if item.get("type").and_then(Value::as_str) == Some("function_call_output") {
let Some(call_id) = item.get("call_id").and_then(Value::as_str) else {
return;
};
if !self.seen_tool_results.insert(call_id.to_string()) {
return;
}
}
self.input.push(sanitize(item));
}
ProviderConversationItem::ToolResult(result) => {
if self.seen_tool_results.insert(result.call_id.clone()) {
self.input.push(provider_tool_result_item(result));
}
}
ProviderConversationItem::LegacyReplayNote {
event_type,
content,
} => {
self.input.push(json!({
"role": "user",
"content": ProviderConversationItem::legacy_note_text(event_type, content),
}));
}
}
}
}
fn sanitize_codex_replayed_response_item(item: &Value) -> Value {
let mut item = item.clone();
let content_is_null = item.get("content") == Some(&Value::Null);
if item.get("role").is_some()
&& let Value::Object(fields) = &mut item
{
if content_is_null {
fields.insert("content".to_string(), json!(" "));
}
fields.remove("tool_calls");
}
if matches!(
item.get("type").and_then(Value::as_str),
Some("reasoning" | "function_call")
) && let Value::Object(fields) = &mut item
{
fields.remove("id");
fields.remove("status");
}
item
}
fn sanitize_custom_stateless_response_item(item: &Value) -> Value {
let mut item = item.clone();
if matches!(
item.get("type").and_then(Value::as_str),
Some("reasoning" | "function_call")
) && let Value::Object(fields) = &mut item
{
fields.remove("id");
fields.remove("status");
}
item
}
fn message_json(message: &ChatMessage) -> Value {
json!({
"role": message.role.as_api_str(),
"content": message.content,
})
}
fn provider_tool_result_item(result: &ProviderToolResult) -> Value {
json!({
"type": "function_call_output",
"call_id": result.call_id,
"output": result.output,
})
}
fn insert_reasoning(
body: &mut Value,
thinking_level: crate::thinking::ThinkingLevel,
send_default_reasoning_summary: bool,
) {
if !send_default_reasoning_summary {
return;
}
let mut reasoning = json!({"summary": "auto"});
if let Some(effort) = thinking_level.explicit_effort() {
reasoning["effort"] = json!(effort);
}
body["reasoning"] = reasoning;
}
#[cfg(test)]
mod tests {
use super::*;
use crate::providers::{
ChatMessage, ProviderConversationItem, ProviderRequest, ProviderToolResult,
};
use crate::thinking::ThinkingLevel;
fn request(level: ThinkingLevel, summary: bool) -> ProviderRequest {
ProviderRequest::new("model", vec![ChatMessage::user("hello")])
.with_thinking_level(level)
.with_default_reasoning_summary(summary)
}
#[test]
fn chat_body_serializes_xhigh_and_max_efforts() {
assert_eq!(
openai_compatible_chat_completions_body(
"gpt-5.5",
&request(ThinkingLevel::XHigh, true)
)["reasoning_effort"],
json!("xhigh")
);
assert_eq!(
openai_compatible_chat_completions_body("glm-5.2", &request(ThinkingLevel::Max, true))
["reasoning_effort"],
json!("max")
);
}
#[test]
fn responses_body_serializes_xhigh_max_and_omits_default_effort() {
assert_eq!(
openai_compatible_responses_body("gpt-5.5", &request(ThinkingLevel::XHigh, true))["reasoning"]
["effort"],
json!("xhigh")
);
assert_eq!(
openai_compatible_responses_body("glm-5.2", &request(ThinkingLevel::Max, true))["reasoning"]
["effort"],
json!("max")
);
let body =
openai_compatible_responses_body("gpt-5", &request(ThinkingLevel::Default, true));
assert_eq!(body["reasoning"]["summary"], json!("auto"));
assert!(body["reasoning"].get("effort").is_none());
}
#[test]
fn provider_bodies_append_dynamic_mcp_tools() {
let dynamic = json!({
"type":"function",
"name":"mcp__mock__echo",
"description":"Echo",
"parameters":{"type":"object","properties":{"text":{"type":"string"}}}
});
let request = ProviderRequest::new("model-a", vec![ChatMessage::user("inspect")])
.with_dynamic_tool_definitions(vec![dynamic.clone()]);
let responses_body = openai_compatible_responses_body("model-a", &request);
let response_tools = responses_body["tools"].as_array().unwrap();
assert_eq!(response_tools.last().unwrap(), &dynamic);
let chat_body = openai_compatible_chat_completions_body("model-a", &request);
let chat_tool = chat_body["tools"].as_array().unwrap().last().unwrap();
assert_eq!(chat_tool["function"]["name"], "mcp__mock__echo");
assert_eq!(chat_tool["function"]["parameters"], dynamic["parameters"]);
let codex_body = codex_responses_body("model-a", &request);
assert_eq!(
codex_body["tools"].as_array().unwrap().last().unwrap()["name"],
"mcp__mock__echo"
);
}
#[test]
fn codex_body_omits_anthropic_thinking_items_but_preserves_replay_items() {
let request = ProviderRequest::from_conversation(
"gpt-5",
vec![
ProviderConversationItem::Message(ChatMessage::user("before")),
ProviderConversationItem::ResponseItem(json!({
"type": "thinking",
"thinking": "synthetic private thought",
"signature": "placeholder-signature"
})),
ProviderConversationItem::ResponseItem(json!({
"type": "redacted_thinking",
"data": "placeholder-redacted"
})),
ProviderConversationItem::ResponseItem(json!({
"id": "rs_codex",
"type": "reasoning",
"summary": [],
"status": "completed"
})),
ProviderConversationItem::ResponseItem(json!({
"type": "function_call",
"call_id": "call_codex",
"name": "read",
"arguments": "{}"
})),
ProviderConversationItem::ToolResult(ProviderToolResult {
call_id: "call_codex".to_string(),
tool_name: "read".to_string(),
success: true,
output: "synthetic result".to_string(),
}),
ProviderConversationItem::Message(ChatMessage::user("after")),
],
);
let body = codex_responses_body("gpt-5", &request);
let input = body["input"].as_array().unwrap();
assert!(input.iter().all(|item| {
!matches!(
item.get("type").and_then(Value::as_str),
Some("thinking" | "redacted_thinking")
)
}));
assert_eq!(
input
.iter()
.map(|item| item["type"].as_str())
.collect::<Vec<_>>(),
vec![
None,
Some("reasoning"),
Some("function_call"),
Some("function_call_output"),
None
]
);
assert!(input[1].get("id").is_none());
assert!(input[1].get("status").is_none());
assert_eq!(input[2]["call_id"], input[3]["call_id"]);
assert_eq!(input[4]["content"], "after");
}
#[test]
fn all_disabled_tools_omit_openai_tool_fields() {
let disabled = crate::tools::MVP_TOOL_CAPABILITIES
.iter()
.map(|tool| tool.canonical_name().to_string())
.collect::<Vec<_>>();
let request = ProviderRequest::new("model-a", vec![ChatMessage::user("inspect")])
.with_disabled_tool_names(disabled);
let responses_body = openai_compatible_responses_body("model-a", &request);
assert!(responses_body.get("tools").is_none());
assert!(responses_body.get("tool_choice").is_none());
let chat_body = openai_compatible_chat_completions_body("model-a", &request);
assert!(chat_body.get("tools").is_none());
assert!(chat_body.get("tool_choice").is_none());
let codex_body = codex_responses_body("model-a", &request);
assert!(codex_body.get("tools").is_none());
assert!(codex_body.get("tool_choice").is_none());
assert!(codex_body.get("parallel_tool_calls").is_none());
}
#[test]
fn codex_body_uses_request_summary_gate_not_model_rules() {
let body = codex_responses_body("unsupported", &request(ThinkingLevel::XHigh, true));
assert_eq!(body["reasoning"]["effort"], json!("xhigh"));
let body = codex_responses_body("gpt-5.5", &request(ThinkingLevel::XHigh, false));
assert!(body.get("reasoning").is_none());
}
#[test]
fn gpt_like_defaulted_provider_emits_high_effort_both_endpoints() {
let chat = openai_compatible_chat_completions_body_with_protocol(
"gpt-5.6",
&request(ThinkingLevel::High, true),
crate::config::CustomReasoningProtocol::GptLike,
None,
);
assert_eq!(chat["reasoning_effort"], json!("high"));
assert!(chat.get("reasoning").is_none());
assert!(chat.get("thinking").is_none());
let responses = openai_compatible_responses_body_with_protocol(
"gpt-5.6",
&request(ThinkingLevel::High, true),
crate::config::CustomReasoningProtocol::GptLike,
None,
);
assert_eq!(responses["reasoning"]["summary"], json!("auto"));
assert_eq!(responses["reasoning"]["effort"], json!("high"));
assert!(responses.get("thinking").is_none());
}
#[test]
fn gpt_like_default_level_omits_chat_effort_but_keeps_responses_summary() {
let chat = openai_compatible_chat_completions_body_with_protocol(
"gpt-5.6",
&request(ThinkingLevel::Default, true),
crate::config::CustomReasoningProtocol::GptLike,
None,
);
assert!(chat.get("reasoning_effort").is_none());
assert!(chat.get("reasoning").is_none());
assert!(chat.get("thinking").is_none());
let responses = openai_compatible_responses_body_with_protocol(
"gpt-5.6",
&request(ThinkingLevel::Default, true),
crate::config::CustomReasoningProtocol::GptLike,
None,
);
assert_eq!(responses["reasoning"]["summary"], json!("auto"));
assert!(responses["reasoning"].get("effort").is_none());
assert!(responses.get("thinking").is_none());
}
#[test]
fn gpt_like_explicit_efforts_accept_max_and_clamp_unsupported_low() {
let max = openai_compatible_chat_completions_body_with_protocol(
"alias",
&request(ThinkingLevel::Max, true),
crate::config::CustomReasoningProtocol::GptLike,
None,
);
assert_eq!(max["reasoning_effort"], json!("max"));
let collapsed = openai_compatible_chat_completions_body_with_protocol(
"alias",
&request(ThinkingLevel::Low, false),
crate::config::CustomReasoningProtocol::GptLike,
None,
);
assert!(collapsed.get("reasoning_effort").is_none());
assert!(collapsed.get("reasoning").is_none());
assert!(collapsed.get("thinking").is_none());
}
}