chat_responses/api/types/
request.rs1use base64::{Engine as _, engine::general_purpose::STANDARD};
2use chat_core::{
3 error::ChatError,
4 types::{
5 messages::{
6 Messages,
7 content::{Content, RoleEnum},
8 file::FileSource,
9 parts::PartEnum,
10 },
11 options::ChatOptions,
12 tools::ToolDeclarations,
13 },
14};
15use schemars::Schema;
16use serde::Serialize;
17use serde_json::{Value, json};
18
19fn mime_to_ext(mime: &str) -> &'static str {
20 match mime {
21 "application/pdf" => "pdf",
22 "application/json" => "json",
23 "application/zip" => "zip",
24 "application/msword" => "doc",
25 "application/vnd.openxmlformats-officedocument.wordprocessingml.document" => "docx",
26 "application/vnd.ms-excel" => "xls",
27 "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" => "xlsx",
28 "text/plain" => "txt",
29 "text/csv" => "csv",
30 "text/html" => "html",
31 "text/markdown" => "md",
32 _ => "bin",
33 }
34}
35
36#[derive(Debug, Serialize)]
37pub struct ReasoningConfig {
38 pub effort: String,
39 pub summary: String,
40}
41
42#[derive(Debug, Serialize, Default)]
43pub struct ResponsesRequest {
44 pub model: String,
45
46 #[serde(skip_serializing_if = "Option::is_none")]
47 pub input: Option<Vec<Value>>,
48
49 #[serde(skip_serializing_if = "Option::is_none")]
50 pub instructions: Option<String>,
51
52 #[serde(skip_serializing_if = "Option::is_none")]
53 pub temperature: Option<f32>,
54 #[serde(skip_serializing_if = "Option::is_none")]
55 pub top_p: Option<f32>,
56 #[serde(skip_serializing_if = "Option::is_none")]
57 pub max_output_tokens: Option<u32>,
58
59 #[serde(skip_serializing_if = "Option::is_none")]
60 pub reasoning: Option<ReasoningConfig>,
61
62 #[serde(skip_serializing_if = "Option::is_none")]
63 pub tools: Option<Vec<Value>>,
64
65 #[serde(skip_serializing_if = "Option::is_none")]
66 pub text: Option<Value>,
67
68 #[serde(skip_serializing_if = "Option::is_none")]
69 pub stream: Option<bool>,
70
71 #[serde(skip_serializing_if = "Option::is_none")]
72 pub previous_response_id: Option<String>,
73
74 #[serde(skip_serializing_if = "Option::is_none")]
75 pub store: Option<bool>,
76}
77
78pub struct ResponsesRequestConfig<'a> {
79 pub model_name: &'a str,
80 pub messages: &'a Messages,
81 pub tool_declarations: Option<&'a dyn ToolDeclarations>,
84 pub extra_tool_declarations: &'a [Value],
88 pub reasoning_effort: Option<String>,
89 pub options: Option<&'a ChatOptions>,
90 pub output_shape: Option<&'a Schema>,
91 pub previous_response_id: Option<String>,
92 pub store: Option<bool>,
93}
94
95impl ResponsesRequest {
96 pub fn from_core(config: ResponsesRequestConfig<'_>) -> Result<Self, ChatError> {
97 let ResponsesRequestConfig {
98 model_name,
99 messages,
100 tool_declarations,
101 extra_tool_declarations,
102 reasoning_effort,
103 options,
104 output_shape,
105 previous_response_id,
106 store,
107 } = config;
108 let mut req = Self {
109 model: model_name.to_string(),
110 reasoning: reasoning_effort.map(|effort| ReasoningConfig {
111 effort,
112 summary: "auto".to_string(),
113 }),
114 store,
115 ..Default::default()
116 };
117
118 if let Some(opts) = options {
119 req.temperature = opts.temperature;
120 req.top_p = opts.top_p;
121 req.max_output_tokens = opts.max_tokens;
122 }
123
124 if let Some(schema) = output_shape {
125 req.text = Some(json!({
126 "format": {
127 "type": "json_schema",
128 "name": "structured_output",
129 "strict": false,
130 "schema": schema
131 }
132 }));
133 }
134
135 let mut tools_list = Vec::new();
136 if let Some(decls) = tool_declarations {
137 let value = decls.json().map_err(|e| ChatError::Other(e.to_string()))?;
138 if let Value::Array(funcs) = value {
139 for func in funcs {
140 let mut func = func;
141 func["type"] = json!("function");
142 tools_list.push(func);
143 }
144 }
145 }
146 for decl in extra_tool_declarations {
147 tools_list.push(decl.clone());
148 }
149 if !tools_list.is_empty() {
150 req.tools = Some(tools_list);
151 }
152
153 if let Some(prev_id) = previous_response_id {
154 req.previous_response_id = Some(prev_id);
155
156 let boundary = messages.0.iter().rposition(|c| c.role == RoleEnum::Model);
157
158 let mut input = Vec::new();
159
160 if let Some(idx) = boundary {
161 for part in &messages.0[idx].parts.0 {
162 if let PartEnum::Tool(tool) = part {
163 let (_fc, maybe_fr) = tool.to_tuple();
164 if let Some(fr) = maybe_fr {
165 let output = if fr.result.is_string() {
166 fr.result.as_str().unwrap().to_string()
167 } else {
168 fr.result.to_string()
169 };
170 input.push(json!({
171 "type": "function_call_output",
172 "call_id": fr.id.clone().map(String::from).unwrap_or_default(),
173 "output": output,
174 }));
175 }
176 }
177 }
178 }
179
180 let tail_start = boundary.map(|i| i + 1).unwrap_or(0);
181 for content in &messages.0[tail_start..] {
182 content_to_input_items(content, &mut input);
183 }
184 req.input = Some(input);
185 } else {
186 let mut input = Vec::new();
187 let mut instructions = Vec::new();
188
189 for content in &messages.0 {
190 if content.role == RoleEnum::System {
191 for part in &content.parts.0 {
192 if let PartEnum::Text(t) = part {
193 instructions.push(t.0.clone());
194 }
195 }
196 } else {
197 content_to_input_items(content, &mut input);
198 }
199 }
200
201 if !instructions.is_empty() {
202 req.instructions = Some(instructions.join("\n"));
203 }
204 req.input = Some(input);
205 }
206
207 Ok(req)
208 }
209}
210
211fn content_to_input_items(content: &Content, items: &mut Vec<Value>) {
212 let role = match content.role {
213 RoleEnum::User => "user",
214 RoleEnum::Model => "assistant",
215 RoleEnum::System => "system",
216 };
217
218 let mut message_parts: Vec<Value> = Vec::new();
219
220 for part in &content.parts.0 {
221 match part {
222 PartEnum::Text(t) => {
223 let part_type = if role == "assistant" {
224 "output_text"
225 } else {
226 "input_text"
227 };
228 message_parts.push(json!({ "type": part_type, "text": t.0 }));
229 }
230 PartEnum::Reasoning(r) => {
231 message_parts.push(json!({ "type": "input_text", "text": r.text }));
232 }
233 PartEnum::Tool(tool) => {
234 let (fc, maybe_fr) = tool.to_tuple();
235 items.push(json!({
236 "type": "function_call",
237 "call_id": fc.id.clone().map(String::from).unwrap_or_default(),
238 "name": fc.name,
239 "arguments": serde_json::to_string(&fc.arguments).unwrap_or_default(),
240 }));
241 if let Some(fr) = maybe_fr {
242 let output = if fr.result.is_string() {
243 fr.result.as_str().unwrap().to_string()
244 } else {
245 fr.result.to_string()
246 };
247 items.push(json!({
248 "type": "function_call_output",
249 "call_id": fr.id.clone().map(String::from).unwrap_or_default(),
250 "output": output,
251 }));
252 }
253 }
254 PartEnum::File(file) => {
255 let file_id = file.meta.get("openai_file_id").and_then(|v| v.as_str());
256 let part_type = if file.is_image() {
257 "input_image"
258 } else {
259 "input_file"
260 };
261
262 if let Some(id) = file_id {
263 message_parts.push(json!({ "type": part_type, "file_id": id }));
264 continue;
265 }
266
267 match &file.source {
268 FileSource::Url(url) if file.is_image() => {
269 message_parts.push(json!({
270 "type": "input_image",
271 "image_url": url.to_string(),
272 }));
273 }
274 FileSource::Bytes(bytes) if file.is_image() => {
275 let b64 = STANDARD.encode(bytes);
276 let uri = format!("data:{};base64,{}", file.mime, b64);
277 message_parts.push(json!({
278 "type": "input_image",
279 "image_url": uri,
280 }));
281 }
282 FileSource::Url(url) => {
283 message_parts.push(json!({
284 "type": "input_file",
285 "file_url": url.to_string(),
286 }));
287 }
288 FileSource::Bytes(bytes) => {
289 let b64 = STANDARD.encode(bytes);
290 let filename = file
291 .meta
292 .get("filename")
293 .and_then(|v| v.as_str())
294 .map(str::to_owned)
295 .unwrap_or_else(|| {
296 let ext = mime_to_ext(file.mime.as_str());
297 format!("file.{ext}")
298 });
299 message_parts.push(json!({
300 "type": "input_file",
301 "filename": filename,
302 "file_data": format!("data:{};base64,{}", file.mime, b64),
303 }));
304 }
305 }
306 }
307 PartEnum::Structured(_) | PartEnum::Embeddings(_) => {}
308 }
309 }
310
311 if !message_parts.is_empty() {
312 items.push(json!({
313 "role": role,
314 "content": message_parts,
315 }));
316 }
317}