chat_responses/api/types/
response.rs1use chat_core::{
2 error::ChatError,
3 types::{
4 messages::{
5 content::{CompleteReasonEnum, Content, RoleEnum},
6 file::File,
7 parts::{PartEnum, Parts},
8 reasoning::Reasoning,
9 text::Text,
10 },
11 metadata::{Metadata, usage::Usage},
12 response::ChatResponse,
13 },
14};
15use serde::Deserialize;
16use serde_json::Value;
17use tools_rs::FunctionCall;
18
19#[derive(Debug, Deserialize)]
20pub struct ResponsesApiResponse {
21 pub id: Option<String>,
22 pub model: Option<String>,
23 pub output: Vec<ResponsesOutputItem>,
24 pub usage: Option<ResponsesUsage>,
25 pub status: Option<String>,
26}
27
28#[derive(Debug, Clone, Deserialize)]
29#[serde(tag = "type")]
30pub enum ResponsesOutputItem {
31 #[serde(rename = "message")]
32 Message(ResponsesMessage),
33 #[serde(rename = "function_call")]
34 FunctionCall(ResponsesFunctionCall),
35 #[serde(rename = "reasoning")]
36 Reasoning(ResponsesReasoning),
37 #[serde(rename = "web_search_call")]
38 WebSearchCall(ResponsesWebSearchCall),
39 #[serde(rename = "image_generation_call")]
40 ImageGenerationCall(ResponsesImageGenerationCall),
41 #[serde(other)]
42 Unknown,
43}
44
45#[derive(Debug, Clone, Deserialize)]
46pub struct ResponsesImageGenerationCall {
47 pub result: Option<String>,
50 pub output_format: Option<String>,
53}
54
55#[derive(Debug, Clone, Deserialize)]
56pub struct ResponsesMessage {
57 pub content: Vec<ResponsesContentPart>,
58}
59
60#[derive(Debug, Clone, Deserialize)]
61#[serde(tag = "type")]
62pub enum ResponsesContentPart {
63 #[serde(rename = "output_text")]
64 OutputText { text: String },
65 #[serde(rename = "output_image")]
66 OutputImage { image_url: Option<String> },
67 #[serde(other)]
68 Unknown,
69}
70
71#[derive(Debug, Clone, Deserialize)]
72pub struct ResponsesFunctionCall {
73 pub call_id: Option<String>,
74 pub name: Option<String>,
75 pub arguments: Option<String>,
76}
77
78#[derive(Debug, Clone, Deserialize)]
79pub struct ResponsesReasoning {
80 pub summary: Option<Vec<ResponsesSummaryPart>>,
81}
82
83#[derive(Debug, Clone, Deserialize)]
84#[serde(tag = "type")]
85pub enum ResponsesSummaryPart {
86 #[serde(rename = "summary_text")]
87 SummaryText { text: String },
88}
89
90#[derive(Debug, Clone, Deserialize)]
91pub struct ResponsesWebSearchCall {}
92
93#[derive(Debug, Deserialize)]
94pub struct ResponsesUsage {
95 #[serde(alias = "prompt_tokens")]
96 pub input_tokens: Option<usize>,
97 #[serde(alias = "completion_tokens")]
98 pub output_tokens: Option<usize>,
99 pub total_tokens: Option<usize>,
100}
101
102fn append_content_part(parts: &mut Parts, content_part: &ResponsesContentPart) {
103 match content_part {
104 ResponsesContentPart::OutputText { text } => {
105 if let Ok(value) = serde_json::from_str::<Value>(text)
106 && (value.is_object() || value.is_array())
107 {
108 parts.push(PartEnum::Structured(value));
109 return;
110 }
111 parts.push(PartEnum::Text(Text::new(text.clone())));
112 }
113 ResponsesContentPart::OutputImage { image_url } => {
114 if let Some(url_str) = image_url
115 && let Ok(file) = File::from_url(url_str, None)
116 {
117 parts.push(PartEnum::File(file));
118 }
119 }
120 ResponsesContentPart::Unknown => {}
121 }
122}
123
124pub fn output_items_to_parts(output: &[ResponsesOutputItem]) -> (Parts, bool) {
127 let mut parts = Parts::default();
128 let mut has_function_call = false;
129
130 for item in output {
131 match item {
132 ResponsesOutputItem::Message(msg) => {
133 for content_part in &msg.content {
134 append_content_part(&mut parts, content_part);
135 }
136 }
137 ResponsesOutputItem::FunctionCall(fc) => {
138 has_function_call = true;
139 let arguments: Value = fc
140 .arguments
141 .as_deref()
142 .map(|s| serde_json::from_str(s).unwrap_or_default())
143 .unwrap_or_default();
144
145 parts.push(PartEnum::from_function_call(FunctionCall {
146 id: fc.call_id.clone().map(Into::into),
147 name: fc.name.clone().unwrap_or_default(),
148 arguments,
149 }));
150 }
151 ResponsesOutputItem::Reasoning(r) => {
152 if let Some(summary) = &r.summary {
153 for sp in summary {
154 let ResponsesSummaryPart::SummaryText { text } = sp;
155 parts.push(PartEnum::Reasoning(Reasoning::new(text.clone())));
156 }
157 }
158 }
159 ResponsesOutputItem::ImageGenerationCall(call) => {
160 if let Some(b64) = &call.result {
161 match base64::Engine::decode(&base64::engine::general_purpose::STANDARD, b64) {
162 Ok(bytes) => {
163 let mime = call
164 .output_format
165 .as_deref()
166 .map(|fmt| format!("image/{fmt}"))
167 .unwrap_or_else(|| "image/png".to_string());
168 parts.push(PartEnum::File(File::from_bytes_with_mime(bytes, mime)));
169 }
170 Err(e) => {
171 eprintln!(
172 "responses: failed to decode image_generation_call result (output_format={:?}): {e}",
173 call.output_format,
174 );
175 }
176 }
177 }
178 }
179 ResponsesOutputItem::WebSearchCall(_) | ResponsesOutputItem::Unknown => {}
180 }
181 }
182
183 (parts, has_function_call)
184}
185
186impl ResponsesApiResponse {
187 pub fn into_core_chat_response(self) -> Result<(ChatResponse, Option<String>), ChatError> {
188 let response_id = self.id.clone();
189 let (parts, has_function_call) = output_items_to_parts(&self.output);
190
191 let complete_reason = if has_function_call {
192 CompleteReasonEnum::ToolCall
193 } else {
194 match self.status.as_deref() {
195 Some("completed") => CompleteReasonEnum::Stop,
196 Some("incomplete") => CompleteReasonEnum::MaxTokens,
197 Some(other) => CompleteReasonEnum::Other(other.to_string()),
198 None => CompleteReasonEnum::None,
199 }
200 };
201
202 let metadata = Metadata {
203 id: self.id,
204 model_slug: self.model,
205 usage: self
206 .usage
207 .map(|u| Usage {
208 input_tokens: u.input_tokens.unwrap_or(0),
209 output_tokens: u.output_tokens.unwrap_or(0),
210 total_tokens: u.total_tokens.unwrap_or(0),
211 })
212 .unwrap_or_default(),
213 ..Default::default()
214 };
215
216 Ok((
217 ChatResponse {
218 content: Content {
219 role: RoleEnum::Model,
220 parts,
221 complete_reason,
222 },
223 metadata: Some(metadata),
224 },
225 response_id,
226 ))
227 }
228}
229
230#[cfg(test)]
231mod tests {
232 use super::*;
233 use chat_core::types::messages::file::FileSource;
234
235 #[test]
236 fn image_generation_call_decoded_to_file_image() {
237 let body = r#"{
238 "output": [
239 {
240 "type": "image_generation_call",
241 "id": "ig_1",
242 "result": "aGk=",
243 "output_format": "png",
244 "status": "completed"
245 }
246 ]
247 }"#;
248
249 let resp: ResponsesApiResponse = serde_json::from_str(body).unwrap();
250 let (parts, _) = output_items_to_parts(&resp.output);
251
252 let file = parts
253 .into_iter()
254 .find_map(|p| match p {
255 PartEnum::File(f) => Some(f),
256 _ => None,
257 })
258 .expect("expected a File part");
259
260 assert!(file.is_image());
261 assert_eq!(file.mime.as_str(), "image/png");
262 match file.source {
263 FileSource::Bytes(bytes) => assert_eq!(bytes, b"hi"),
264 other => panic!("expected Bytes source, got {other:?}"),
265 }
266 }
267
268 #[test]
269 fn image_generation_call_without_result_is_skipped() {
270 let body = r#"{
271 "output": [
272 { "type": "image_generation_call", "id": "ig_1", "status": "in_progress" }
273 ]
274 }"#;
275 let resp: ResponsesApiResponse = serde_json::from_str(body).unwrap();
276 let (parts, _) = output_items_to_parts(&resp.output);
277 assert!(parts.is_empty());
278 }
279}