Skip to main content

chat_responses/api/types/
response.rs

1use 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    /// Base64-encoded image payload. Present when the model has produced a
48    /// finalised image; absent during intermediate status frames.
49    pub result: Option<String>,
50    /// Output format hint (e.g. `"png"`, `"jpeg"`). Used to synthesise a
51    /// mimetype when present.
52    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
124/// Converts a slice of Responses API output items into core parts.
125/// Returns the parts and whether any function calls were present.
126pub 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}