Skip to main content

chat_responses/api/types/
request.rs

1use 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    /// Tool declarations view from `Chat`. The builder calls `.json()`
82    /// on it to obtain the JSON array of function decls.
83    pub tool_declarations: Option<&'a dyn ToolDeclarations>,
84    /// Pre-materialized provider-specific tool declarations (e.g.
85    /// OpenAI's `web_search`, `image_generation`). The wire layer
86    /// drops these into `tools[]` opaquely.
87    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}