use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use url::Url;
use crate::{
chat::{ServiceTier, create::request::ReasoningEffort},
errors::OapiError,
rest::post::{Post, PostNoStream, PostStream},
};
#[derive(Serialize, Debug, Default, Clone)]
pub struct RequestBody {
#[serde(skip_serializing_if = "Option::is_none")]
pub background: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub conversation: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub include: Option<Vec<String>>,
pub input: Input,
#[serde(skip_serializing_if = "Option::is_none")]
pub instructions: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_output_tokens: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_tool_calls: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<HashMap<String, String>>,
pub model: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub parallel_tool_calls: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub previous_response_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub prompt_cache_key: Option<String>,
#[cfg(feature = "qwen")]
#[serde(skip_serializing_if = "Option::is_none")]
pub enable_thinking: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning: Option<ReasoningConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub safety_identifier: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub service_tier: Option<ServiceTier>,
#[serde(skip_serializing_if = "Option::is_none")]
pub store: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stream: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub temperature: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub text: Option<TextConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_choice: Option<ToolChoice>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tools: Option<Vec<Tool>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub top_logprobs: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub top_p: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub truncation: Option<Truncation>,
#[serde(skip_serializing_if = "Option::is_none")]
pub user: Option<String>,
#[serde(flatten, skip_serializing_if = "Option::is_none")]
pub extra_body_map: Option<serde_json::Map<String, serde_json::Value>>,
}
#[derive(Serialize, Debug, Clone)]
#[serde(untagged)]
pub enum Input {
Text(String),
Items(Vec<InputItem>),
}
impl Default for Input {
fn default() -> Self {
Self::Text(String::new())
}
}
impl From<&str> for Input {
fn from(value: &str) -> Self {
Self::Text(value.to_string())
}
}
impl From<String> for Input {
fn from(value: String) -> Self {
Self::Text(value)
}
}
impl From<Vec<InputItem>> for Input {
fn from(value: Vec<InputItem>) -> Self {
Self::Items(value)
}
}
#[derive(Serialize, Debug, Clone)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum InputItem {
Message {
role: Role,
content: MessageContent,
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
status: Option<String>,
},
FunctionCall {
call_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
arguments: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<String>,
},
FunctionCallOutput {
call_id: String,
output: FunctionCallOutputContent,
},
Reasoning {
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
content: Option<Vec<ReasoningTextPart>>,
#[serde(skip_serializing_if = "Option::is_none")]
summary: Option<Vec<SummaryTextPart>>,
},
WebSearchCall {
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
action: Option<serde_json::Value>,
},
CustomToolCall {
call_id: String,
name: String,
input: String,
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<String>,
},
CustomToolCallOutput {
call_id: String,
output: FunctionCallOutputContent,
},
#[serde(untagged)]
Other(serde_json::Value),
}
#[derive(Serialize, Debug, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Role {
User,
Assistant,
System,
Developer,
}
#[derive(Serialize, Debug, Clone)]
#[serde(untagged)]
pub enum MessageContent {
Text(String),
Parts(Vec<InputContentPart>),
}
impl From<&str> for MessageContent {
fn from(value: &str) -> Self {
Self::Text(value.to_string())
}
}
impl From<String> for MessageContent {
fn from(value: String) -> Self {
Self::Text(value)
}
}
impl From<Vec<InputContentPart>> for MessageContent {
fn from(value: Vec<InputContentPart>) -> Self {
Self::Parts(value)
}
}
#[derive(Serialize, Debug, Clone)]
#[serde(untagged)]
pub enum FunctionCallOutputContent {
Text(String),
Parts(Vec<InputContentPart>),
}
impl From<&str> for FunctionCallOutputContent {
fn from(value: &str) -> Self {
Self::Text(value.to_string())
}
}
impl From<String> for FunctionCallOutputContent {
fn from(value: String) -> Self {
Self::Text(value)
}
}
#[derive(Serialize, Debug, Clone)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum InputContentPart {
InputText {
text: String,
},
OutputText {
text: String,
},
InputImage {
#[serde(skip_serializing_if = "Option::is_none")]
image_url: Option<ImageUrl>,
#[serde(skip_serializing_if = "Option::is_none")]
detail: Option<ImageDetail>,
#[serde(skip_serializing_if = "Option::is_none")]
file_id: Option<String>,
},
#[cfg(feature = "qwen")]
InputFile {
file_url: String,
},
}
#[derive(Serialize, Debug, Clone)]
#[serde(untagged)]
pub enum ImageUrl {
Url(String),
Object {
url: String,
},
}
#[derive(Serialize, Debug, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum ImageDetail {
Low,
High,
Auto,
Original,
}
#[derive(Serialize, Debug, Clone)]
#[serde(tag = "type", rename = "reasoning_text")]
pub struct ReasoningTextPart {
pub text: String,
}
#[derive(Serialize, Debug, Clone)]
#[serde(tag = "type", rename = "summary_text")]
pub struct SummaryTextPart {
pub text: String,
}
#[derive(Serialize, Debug, Clone, Default)]
pub struct ReasoningConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub effort: Option<ReasoningEffort>,
#[serde(skip_serializing_if = "Option::is_none")]
pub summary: Option<SummaryEffort>,
}
#[derive(Serialize, Debug, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum SummaryEffort {
Concise,
Detailed,
Auto,
}
#[derive(Serialize, Debug, Clone, Default)]
pub struct TextConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub format: Option<TextFormat>,
}
#[derive(Serialize, Debug, Clone)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum TextFormat {
Text,
JsonObject,
JsonSchema {
name: String,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
schema: serde_json::Value,
#[serde(skip_serializing_if = "Option::is_none")]
strict: Option<bool>,
},
}
#[derive(Serialize, Debug, Clone)]
#[serde(untagged)]
pub enum ToolChoice {
Mode(ToolChoiceMode),
Specific(ToolChoiceSpecific),
}
#[derive(Serialize, Debug, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum ToolChoiceMode {
None,
Auto,
Required,
}
#[derive(Serialize, Debug, Clone)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ToolChoiceSpecific {
Function {
name: String,
},
WebSearch,
Custom {
name: String,
},
}
#[derive(Serialize, Debug, Clone)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Tool {
Function {
name: String,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
parameters: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
strict: Option<bool>,
},
WebSearch,
#[cfg(feature = "qwen")]
WebExtractor,
#[cfg(feature = "qwen")]
CodeInterpreter,
#[cfg(feature = "qwen")]
WebSearchImage,
#[cfg(feature = "qwen")]
ImageSearch,
#[cfg(feature = "qwen")]
FileSearch {
vector_store_ids: Vec<String>,
},
#[cfg(feature = "qwen")]
Mcp {
server_protocol: String,
server_label: String,
server_url: String,
#[serde(skip_serializing_if = "Option::is_none")]
server_description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
headers: Option<HashMap<String, String>>,
},
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Truncation {
Auto,
Disabled,
}
impl RequestBody {
pub fn is_streaming(&self) -> bool {
self.stream.unwrap_or(false)
}
}
impl Post for RequestBody {
fn is_streaming(&self) -> bool {
RequestBody::is_streaming(self)
}
fn build_url(&self, base_url: &str) -> Result<String, OapiError> {
let mut url = Url::parse(base_url.trim_end_matches('/')).map_err(OapiError::UrlError)?;
url.path_segments_mut()
.map_err(|_| OapiError::UrlCannotBeBase(base_url.to_string()))?
.push("responses");
Ok(url.to_string())
}
}
impl PostNoStream for RequestBody {
type Response = crate::responses::Response;
}
impl PostStream for RequestBody {
type Response = super::response::ResponseStreamEvent;
}
#[cfg(test)]
mod tests {
use super::*;
fn base_request() -> RequestBody {
RequestBody {
model: "test-model".to_string(),
input: Input::Text("你好".to_string()),
..Default::default()
}
}
#[test]
fn serializes_minimal_request() {
let value = serde_json::to_value(base_request()).unwrap();
assert_eq!(
value,
serde_json::json!({
"model": "test-model",
"input": "你好",
})
);
}
#[test]
fn serializes_input_items() {
let request = RequestBody {
input: Input::Items(vec![
InputItem::Message {
role: Role::System,
content: MessageContent::Text("You are helpful.".into()),
id: None,
status: None,
},
InputItem::Message {
role: Role::User,
content: MessageContent::Parts(vec![InputContentPart::InputText {
text: "hi".into(),
}]),
id: None,
status: None,
},
InputItem::FunctionCall {
call_id: "fc_1".into(),
name: Some("get_weather".into()),
arguments: Some("{\"city\": \"北京\"}".into()),
id: None,
},
InputItem::FunctionCallOutput {
call_id: "fc_1".into(),
output: FunctionCallOutputContent::Text("sunny".into()),
},
]),
..base_request()
};
let value = serde_json::to_value(request).unwrap();
assert_eq!(
value["input"],
serde_json::json!([
{"type": "message", "role": "system", "content": "You are helpful."},
{"type": "message", "role": "user", "content": [{"type": "input_text", "text": "hi"}]},
{"type": "function_call", "call_id": "fc_1", "name": "get_weather", "arguments": "{\"city\": \"北京\"}"},
{"type": "function_call_output", "call_id": "fc_1", "output": "sunny"},
])
);
}
#[test]
fn serializes_tools_and_tool_choice() {
let request = RequestBody {
tools: Some(vec![Tool::Function {
name: "get_weather".into(),
description: Some("获取天气".into()),
parameters: Some(serde_json::json!({
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"],
})),
strict: None,
}]),
tool_choice: Some(ToolChoice::Specific(ToolChoiceSpecific::Function {
name: "get_weather".into(),
})),
reasoning: Some(ReasoningConfig {
effort: Some(ReasoningEffort::Low),
summary: None,
}),
text: Some(TextConfig {
format: Some(TextFormat::JsonObject),
}),
..base_request()
};
let value = serde_json::to_value(request).unwrap();
assert_eq!(
value["tool_choice"],
serde_json::json!({"type": "function", "name": "get_weather"})
);
assert_eq!(value["reasoning"], serde_json::json!({"effort": "low"}));
assert_eq!(
value["text"],
serde_json::json!({"format": {"type": "json_object"}})
);
assert_eq!(value["tools"][0]["type"], "function");
assert_eq!(value["tools"][0]["name"], "get_weather");
}
#[test]
fn extra_body_flattens_into_top_level() {
let mut request = base_request();
request.extra_body_map = Some(
serde_json::json!({"ocr_options": {}})
.as_object()
.unwrap()
.clone(),
);
let value = serde_json::to_value(request).unwrap();
assert_eq!(value["ocr_options"], serde_json::json!({}));
}
}