1use std::{fmt, sync::Arc, time::Duration};
2
3use base64::{Engine as _, engine::general_purpose::STANDARD};
4use reqwest::{
5 Client, RequestBuilder, Response, StatusCode, Url,
6 header::{AUTHORIZATION, HeaderValue},
7 multipart::{Form, Part},
8 redirect::Policy,
9};
10use serde_json::Value;
11use zeroize::Zeroizing;
12
13use crate::{
14 AgentTurnRequest, AgentTurnResponse, Error, GPT_4O_TRANSCRIBE, GeneratedImage, ImageAnalysis,
15 ImageAnalysisRequest, ImageAnalysisStatus, ImageAnalysisUsage, ImageEditRequest, ImageFormat,
16 ImageGeneration, ImageGenerationRequest, ImageQuality, ImageTokenDetails, ImageUsage,
17 ModelMetadata, Result, Transcription, TranscriptionRequest, TranscriptionTokenDetails,
18 TranscriptionTokenUsage, TranscriptionUsage,
19 agent::{parse_agent_turn, parse_model_metadata, validate_model},
20 error::{clean_message, transport},
21};
22
23const API_BASE: &str = "https://api.openai.com/v1/";
24const DEFAULT_TIMEOUT: Duration = Duration::from_secs(5 * 60);
25const MAX_RESPONSE_BYTES: usize = 128 * 1024 * 1024;
26
27struct ApiKey(Zeroizing<String>);
28
29impl ApiKey {
30 fn new(value: impl Into<String>) -> Result<Self> {
31 let supplied = Zeroizing::new(value.into());
32 let value = supplied.trim();
33 if value.is_empty() {
34 return Err(Error::InvalidApiKey);
35 }
36 let authorization = Zeroizing::new(format!("Bearer {value}"));
37 if HeaderValue::from_str(&authorization).is_err() {
38 return Err(Error::InvalidApiKey);
39 }
40 Ok(Self(Zeroizing::new(value.to_owned())))
41 }
42
43 fn sensitive_authorization(&self) -> Result<HeaderValue> {
44 let authorization = Zeroizing::new(format!("Bearer {}", self.0.as_str()));
45 let mut value = HeaderValue::from_str(&authorization).map_err(|_| Error::InvalidApiKey)?;
46 value.set_sensitive(true);
47 Ok(value)
48 }
49}
50
51impl fmt::Debug for ApiKey {
52 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
53 f.write_str("ApiKey([REDACTED])")
54 }
55}
56
57#[derive(Clone)]
59pub struct OpenAi {
60 api_key: Arc<ApiKey>,
61 client: Client,
62 transcription_endpoint: Url,
63 responses_endpoint: Url,
64 image_generation_endpoint: Url,
65 image_edit_endpoint: Url,
66 models_endpoint: Url,
67}
68
69impl fmt::Debug for OpenAi {
70 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71 f.debug_struct("OpenAi")
72 .field("api_key", &self.api_key)
73 .field("api_base", &API_BASE)
74 .finish_non_exhaustive()
75 }
76}
77
78impl OpenAi {
79 pub fn open(api_key: impl Into<String>) -> Result<Self> {
84 let api_key = ApiKey::new(api_key)?;
85 let base = Url::parse(API_BASE)
86 .map_err(|_| Error::Protocol("compiled API base URL is invalid".into()))?;
87 let client = Client::builder()
88 .timeout(DEFAULT_TIMEOUT)
89 .redirect(Policy::none())
90 .retry(reqwest::retry::never())
91 .referer(false)
92 .no_proxy()
93 .https_only(true)
94 .user_agent(concat!("kcode-openai-api/", env!("CARGO_PKG_VERSION")))
95 .build()
96 .map_err(transport)?;
97 Ok(Self {
98 api_key: Arc::new(api_key),
99 client,
100 transcription_endpoint: base
101 .join("audio/transcriptions")
102 .map_err(|_| Error::Protocol("compiled transcription URL is invalid".into()))?,
103 responses_endpoint: base
104 .join("responses")
105 .map_err(|_| Error::Protocol("compiled Responses API URL is invalid".into()))?,
106 image_generation_endpoint: base
107 .join("images/generations")
108 .map_err(|_| Error::Protocol("compiled image generation URL is invalid".into()))?,
109 image_edit_endpoint: base
110 .join("images/edits")
111 .map_err(|_| Error::Protocol("compiled image edit URL is invalid".into()))?,
112 models_endpoint: base
113 .join("models/")
114 .map_err(|_| Error::Protocol("compiled models URL is invalid".into()))?,
115 })
116 }
117
118 pub async fn transcribe(&self, request: TranscriptionRequest) -> Result<Transcription> {
120 request.validate()?;
121 let TranscriptionRequest {
122 audio,
123 prompt,
124 language,
125 } = request;
126 let (file_name, mime_type, data) = audio.into_parts();
127 let part = Part::bytes(data)
128 .file_name(file_name)
129 .mime_str(&mime_type)
130 .map_err(|_| Error::InvalidInput("audio MIME type is invalid".into()))?;
131 let mut form = Form::new()
132 .part("file", part)
133 .text("model", GPT_4O_TRANSCRIBE)
134 .text("response_format", "json");
135 if let Some(prompt) = prompt {
136 form = form.text("prompt", prompt);
137 }
138 if let Some(language) = language {
139 form = form.text("language", language);
140 }
141 let (payload, request_id) = self
142 .execute(
143 self.client
144 .post(self.transcription_endpoint.clone())
145 .multipart(form),
146 )
147 .await?;
148 parse_transcription(&payload, request_id)
149 }
150
151 pub async fn analyze_image(&self, request: ImageAnalysisRequest) -> Result<ImageAnalysis> {
153 request.validate()?;
154 let (payload, request_id) = self
155 .execute(
156 self.client
157 .post(self.responses_endpoint.clone())
158 .json(&request.payload()),
159 )
160 .await?;
161 parse_image_analysis(&payload, request_id)
162 }
163
164 pub async fn generate_image(&self, request: ImageGenerationRequest) -> Result<ImageGeneration> {
166 request.validate()?;
167 let requested_format = request.output_format;
168 let (payload, request_id) = self
169 .execute(
170 self.client
171 .post(self.image_generation_endpoint.clone())
172 .json(&request.payload()),
173 )
174 .await?;
175 parse_image_generation(&payload, requested_format, request_id)
176 }
177
178 pub async fn edit_image(&self, request: ImageEditRequest) -> Result<ImageGeneration> {
180 request.validate()?;
181 let requested_format = request.output_format;
182 let mut form = Form::new()
183 .text("model", crate::GPT_IMAGE_2)
184 .text("prompt", request.prompt)
185 .text("n", "1")
186 .text("size", request.size.as_api_value())
187 .text("quality", request.quality.as_str())
188 .text("output_format", request.output_format.as_str())
189 .text("background", request.background.as_str())
190 .text("moderation", request.moderation.as_str())
191 .text("stream", "false");
192 for (index, image) in request.images.into_iter().enumerate() {
193 let media_type = image.media_type();
194 let extension = match media_type {
195 crate::ImageMediaType::Png => "png",
196 crate::ImageMediaType::Jpeg => "jpg",
197 crate::ImageMediaType::WebP => "webp",
198 crate::ImageMediaType::Gif => "gif",
199 };
200 let part = Part::bytes(image.data().to_vec())
201 .file_name(format!("reference-{index}.{extension}"))
202 .mime_str(media_type.mime_type())
203 .map_err(|_| Error::InvalidInput("image MIME type is invalid".into()))?;
204 form = form.part("image[]", part);
205 }
206 if let Some(compression) = request.output_compression {
207 form = form.text("output_compression", compression.to_string());
208 }
209 if let Some(user) = request.user {
210 form = form.text("user", user);
211 }
212 let (payload, request_id) = self
213 .execute(
214 self.client
215 .post(self.image_edit_endpoint.clone())
216 .multipart(form),
217 )
218 .await?;
219 parse_image_generation(&payload, requested_format, request_id)
220 }
221
222 pub async fn agent_turn(&self, request: AgentTurnRequest) -> Result<AgentTurnResponse> {
224 request.validate()?;
225 let model = request.model.clone();
226 let (payload, request_id) = self
227 .execute(
228 self.client
229 .post(self.responses_endpoint.clone())
230 .json(&request.payload()),
231 )
232 .await?;
233 parse_agent_turn(&payload, &model, request_id)
234 }
235
236 pub async fn model_metadata(&self, model: &str) -> Result<ModelMetadata> {
238 validate_model(model)?;
239 let mut endpoint = self.models_endpoint.clone();
240 endpoint
241 .path_segments_mut()
242 .map_err(|_| Error::Protocol("models URL cannot accept a model identifier".into()))?
243 .push(model);
244 let (payload, _) = self.execute(self.client.get(endpoint)).await?;
245 parse_model_metadata(&payload, model)
246 }
247
248 async fn execute(&self, request: RequestBuilder) -> Result<(Value, Option<String>)> {
249 let response = request
250 .header(AUTHORIZATION, self.api_key.sensitive_authorization()?)
251 .send()
252 .await
253 .map_err(transport)?;
254 let status = response.status();
255 let request_id = response
256 .headers()
257 .get("x-request-id")
258 .and_then(|value| value.to_str().ok())
259 .map(|value| clean_message(value, 200));
260 let body = bounded_body(response).await?;
261 if !status.is_success() {
262 return Err(provider_error(status, &body, request_id));
263 }
264 let payload = serde_json::from_slice(&body)
265 .map_err(|_| Error::Protocol("response was not valid JSON".into()))?;
266 Ok((payload, request_id))
267 }
268}
269
270async fn bounded_body(mut response: Response) -> Result<Vec<u8>> {
271 if response
272 .content_length()
273 .is_some_and(|value| value > MAX_RESPONSE_BYTES as u64)
274 {
275 return Err(Error::Protocol("response exceeded 128 MiB".into()));
276 }
277 let initial_capacity = response
278 .content_length()
279 .and_then(|value| usize::try_from(value).ok())
280 .unwrap_or(0)
281 .min(MAX_RESPONSE_BYTES);
282 let mut body = Vec::with_capacity(initial_capacity);
283 while let Some(chunk) = response.chunk().await.map_err(transport)? {
284 let length = body
285 .len()
286 .checked_add(chunk.len())
287 .ok_or_else(|| Error::Protocol("response exceeded 128 MiB".into()))?;
288 if length > MAX_RESPONSE_BYTES {
289 return Err(Error::Protocol("response exceeded 128 MiB".into()));
290 }
291 body.extend_from_slice(&chunk);
292 }
293 Ok(body)
294}
295
296fn parse_transcription(payload: &Value, request_id: Option<String>) -> Result<Transcription> {
297 let text = payload
298 .get("text")
299 .and_then(Value::as_str)
300 .map(str::trim)
301 .filter(|value| !value.is_empty())
302 .ok_or_else(|| Error::Protocol("transcription response omitted non-empty text".into()))?
303 .to_owned();
304 let usage = payload
305 .get("usage")
306 .filter(|value| !value.is_null())
307 .map(parse_transcription_usage)
308 .transpose()?;
309 Ok(Transcription {
310 text,
311 usage,
312 request_id,
313 })
314}
315
316fn parse_transcription_usage(value: &Value) -> Result<TranscriptionUsage> {
317 let usage_type = value.get("type").and_then(Value::as_str);
318 if usage_type == Some("duration") {
319 let seconds = value
320 .get("seconds")
321 .and_then(Value::as_f64)
322 .ok_or_else(|| {
323 Error::Protocol("duration transcription usage omitted seconds".into())
324 })?;
325 if !seconds.is_finite() || seconds < 0.0 {
326 return Err(Error::Protocol(
327 "duration transcription usage contained invalid seconds".into(),
328 ));
329 }
330 return Ok(TranscriptionUsage::DurationSeconds(seconds));
331 }
332 if !matches!(usage_type, None | Some("tokens")) {
333 return Err(Error::Protocol(
334 "transcription usage returned an unsupported type".into(),
335 ));
336 }
337 let input_tokens = required_u64(value, "input_tokens", "transcription usage")?;
338 let output_tokens = required_u64(value, "output_tokens", "transcription usage")?;
339 let total_tokens = required_u64(value, "total_tokens", "transcription usage")?;
340 let input_details = value
341 .get("input_token_details")
342 .filter(|details| !details.is_null())
343 .map(|details| {
344 Ok(TranscriptionTokenDetails {
345 audio_tokens: optional_u64(details, "audio_tokens", "transcription usage")?,
346 text_tokens: optional_u64(details, "text_tokens", "transcription usage")?,
347 })
348 })
349 .transpose()?;
350 Ok(TranscriptionUsage::Tokens(TranscriptionTokenUsage {
351 input_tokens,
352 output_tokens,
353 total_tokens,
354 input_details,
355 }))
356}
357
358fn parse_image_analysis(payload: &Value, request_id: Option<String>) -> Result<ImageAnalysis> {
359 let response_id = required_nonempty_string(payload, "id", "image-analysis response")?;
360 let model = required_nonempty_string(payload, "model", "image-analysis response")?;
361 let status = match payload.get("status").and_then(Value::as_str) {
362 Some("completed") => ImageAnalysisStatus::Completed,
363 Some("incomplete") => {
364 let reason = payload
365 .pointer("/incomplete_details/reason")
366 .and_then(Value::as_str)
367 .map(str::trim)
368 .filter(|value| !value.is_empty())
369 .map(|value| clean_message(value, 100));
370 ImageAnalysisStatus::Incomplete { reason }
371 }
372 _ => {
373 return Err(Error::Protocol(
374 "image-analysis response returned an unsupported status".into(),
375 ));
376 }
377 };
378
379 let output = payload
380 .get("output")
381 .and_then(Value::as_array)
382 .ok_or_else(|| Error::Protocol("image-analysis response omitted output".into()))?;
383 let mut fragments = Vec::new();
384 for item in output {
385 if item.get("type").and_then(Value::as_str) != Some("message")
386 || item.get("role").and_then(Value::as_str) != Some("assistant")
387 {
388 continue;
389 }
390 let Some(content) = item.get("content").and_then(Value::as_array) else {
391 continue;
392 };
393 for part in content {
394 if part.get("type").and_then(Value::as_str) != Some("output_text") {
395 continue;
396 }
397 if let Some(text) = part.get("text").and_then(Value::as_str) {
398 let text = text.trim();
399 if !text.is_empty() {
400 fragments.push(text);
401 }
402 }
403 }
404 }
405 let text = fragments.join("\n");
406 if text.is_empty() {
407 return Err(Error::Protocol(
408 "image-analysis response omitted non-empty assistant text".into(),
409 ));
410 }
411
412 let usage = payload
413 .get("usage")
414 .filter(|value| !value.is_null())
415 .map(parse_image_analysis_usage)
416 .transpose()?;
417 Ok(ImageAnalysis {
418 text,
419 response_id,
420 model,
421 status,
422 usage,
423 request_id,
424 })
425}
426
427fn parse_image_analysis_usage(value: &Value) -> Result<ImageAnalysisUsage> {
428 let (cached_input_tokens, cache_write_input_tokens) = match value.get("input_tokens_details") {
429 None | Some(Value::Null) => (None, None),
430 Some(details) => (
431 optional_u64(details, "cached_tokens", "image-analysis usage")?,
432 optional_u64(details, "cache_write_tokens", "image-analysis usage")?,
433 ),
434 };
435 let reasoning_output_tokens = match value.get("output_tokens_details") {
436 None | Some(Value::Null) => None,
437 Some(details) => optional_u64(details, "reasoning_tokens", "image-analysis usage")?,
438 };
439 Ok(ImageAnalysisUsage {
440 input_tokens: required_u64(value, "input_tokens", "image-analysis usage")?,
441 output_tokens: required_u64(value, "output_tokens", "image-analysis usage")?,
442 total_tokens: required_u64(value, "total_tokens", "image-analysis usage")?,
443 cached_input_tokens,
444 cache_write_input_tokens,
445 reasoning_output_tokens,
446 })
447}
448
449fn parse_image_generation(
450 payload: &Value,
451 requested_format: ImageFormat,
452 request_id: Option<String>,
453) -> Result<ImageGeneration> {
454 let created = required_u64(payload, "created", "image generation response")?;
455 let data = payload
456 .get("data")
457 .and_then(Value::as_array)
458 .ok_or_else(|| Error::Protocol("image generation response omitted image data".into()))?;
459 if data.len() != 1 {
460 return Err(Error::Protocol(
461 "single-image request did not return exactly one image".into(),
462 ));
463 }
464 let encoded = data[0]
465 .get("b64_json")
466 .and_then(Value::as_str)
467 .ok_or_else(|| Error::Protocol("generated image omitted base64 data".into()))?;
468 let decoded = STANDARD
469 .decode(encoded)
470 .map_err(|_| Error::Protocol("generated image contained invalid base64".into()))?;
471 if decoded.is_empty() {
472 return Err(Error::Protocol("generated image was empty".into()));
473 }
474 let format = match payload.get("output_format").and_then(Value::as_str) {
475 Some(value) => ImageFormat::parse(value)
476 .ok_or_else(|| Error::Protocol("generated image used an unknown format".into()))?,
477 None => requested_format,
478 };
479 let quality = payload
480 .get("quality")
481 .and_then(Value::as_str)
482 .and_then(ImageQuality::parse);
483 let size = payload
484 .get("size")
485 .and_then(Value::as_str)
486 .map(|value| clean_message(value, 40));
487 let usage = payload
488 .get("usage")
489 .filter(|value| !value.is_null())
490 .map(parse_image_usage)
491 .transpose()?;
492 Ok(ImageGeneration {
493 created,
494 image: GeneratedImage {
495 data: decoded,
496 format,
497 },
498 size,
499 quality,
500 usage,
501 request_id,
502 })
503}
504
505fn parse_image_usage(value: &Value) -> Result<ImageUsage> {
506 Ok(ImageUsage {
507 input_tokens: required_u64(value, "input_tokens", "image usage")?,
508 output_tokens: required_u64(value, "output_tokens", "image usage")?,
509 total_tokens: required_u64(value, "total_tokens", "image usage")?,
510 input_details: parse_image_token_details(
511 value
512 .get("input_tokens_details")
513 .ok_or_else(|| Error::Protocol("image usage omitted input token details".into()))?,
514 )?,
515 output_details: value
516 .get("output_tokens_details")
517 .filter(|details| !details.is_null())
518 .map(parse_image_token_details)
519 .transpose()?,
520 })
521}
522
523fn parse_image_token_details(value: &Value) -> Result<ImageTokenDetails> {
524 Ok(ImageTokenDetails {
525 text_tokens: required_u64(value, "text_tokens", "image token details")?,
526 image_tokens: required_u64(value, "image_tokens", "image token details")?,
527 })
528}
529
530fn required_nonempty_string(value: &Value, field: &str, context: &str) -> Result<String> {
531 value
532 .get(field)
533 .and_then(Value::as_str)
534 .map(str::trim)
535 .filter(|value| !value.is_empty())
536 .map(str::to_owned)
537 .ok_or_else(|| Error::Protocol(format!("{context} omitted non-empty {field}")))
538}
539
540fn required_u64(value: &Value, field: &str, context: &str) -> Result<u64> {
541 value
542 .get(field)
543 .and_then(Value::as_u64)
544 .ok_or_else(|| Error::Protocol(format!("{context} omitted {field}")))
545}
546
547fn optional_u64(value: &Value, field: &str, context: &str) -> Result<Option<u64>> {
548 match value.get(field) {
549 None | Some(Value::Null) => Ok(None),
550 Some(value) => value
551 .as_u64()
552 .map(Some)
553 .ok_or_else(|| Error::Protocol(format!("{context} returned invalid {field}"))),
554 }
555}
556
557fn provider_error(status: StatusCode, body: &[u8], request_id: Option<String>) -> Error {
558 let payload = serde_json::from_slice::<Value>(body).ok();
559 let code = payload
560 .as_ref()
561 .and_then(|value| {
562 value
563 .pointer("/error/code")
564 .and_then(Value::as_str)
565 .or_else(|| value.pointer("/error/type").and_then(Value::as_str))
566 })
567 .map(|value| clean_message(value, 100));
568 let message = payload
569 .as_ref()
570 .and_then(|value| value.pointer("/error/message"))
571 .and_then(Value::as_str)
572 .map(|value| clean_message(value, 400))
573 .unwrap_or_else(|| format!("provider request failed with HTTP {status}"));
574 Error::Provider {
575 status: status.as_u16(),
576 code,
577 message,
578 request_id,
579 }
580}
581
582#[cfg(test)]
583mod tests {
584 use serde_json::json;
585
586 use super::*;
587 use crate::{ImageGenerationRequest, ImageSize};
588
589 #[test]
590 fn debug_and_authorization_header_redact_api_key() {
591 let client = OpenAi::open("secret-api-key").unwrap();
592 let debug = format!("{client:?}");
593 assert!(debug.contains("[REDACTED]"));
594 assert!(!debug.contains("secret-api-key"));
595
596 let header = client.api_key.sensitive_authorization().unwrap();
597 assert!(header.is_sensitive());
598 let request = client
599 .client
600 .post(client.transcription_endpoint.clone())
601 .header(AUTHORIZATION, header);
602 assert!(!format!("{request:?}").contains("secret-api-key"));
603 }
604
605 #[test]
606 fn transcription_response_normalizes_token_usage() {
607 let payload = json!({
608 "text": " hello world ",
609 "usage": {
610 "type": "tokens",
611 "input_tokens": 12,
612 "output_tokens": 3,
613 "total_tokens": 15,
614 "input_token_details": {"audio_tokens": 10, "text_tokens": 2}
615 }
616 });
617 let parsed = parse_transcription(&payload, Some("req_123".into())).unwrap();
618 assert_eq!(parsed.text, "hello world");
619 assert_eq!(parsed.request_id.as_deref(), Some("req_123"));
620 assert_eq!(
621 parsed.usage,
622 Some(TranscriptionUsage::Tokens(TranscriptionTokenUsage {
623 input_tokens: 12,
624 output_tokens: 3,
625 total_tokens: 15,
626 input_details: Some(TranscriptionTokenDetails {
627 audio_tokens: Some(10),
628 text_tokens: Some(2),
629 }),
630 }))
631 );
632 }
633
634 #[test]
635 fn image_analysis_response_normalizes_ordered_text_and_usage() {
636 let payload = json!({
637 "id": "resp_123",
638 "status": "completed",
639 "model": "gpt-5.6-2026-07-01",
640 "output": [
641 {"type": "reasoning", "summary": []},
642 {
643 "type": "message",
644 "role": "assistant",
645 "content": [
646 {"type": "output_text", "text": " first observation "},
647 {"type": "refusal", "refusal": "ignored"}
648 ]
649 },
650 {
651 "type": "message",
652 "role": "assistant",
653 "content": [
654 {"type": "output_text", "text": "second observation"}
655 ]
656 }
657 ],
658 "usage": {
659 "input_tokens": 40,
660 "output_tokens": 12,
661 "total_tokens": 52,
662 "input_tokens_details": {
663 "cached_tokens": 3,
664 "cache_write_tokens": 2
665 },
666 "output_tokens_details": {"reasoning_tokens": 4}
667 }
668 });
669 let parsed = parse_image_analysis(&payload, Some("req_vision".into())).unwrap();
670 assert_eq!(parsed.text, "first observation\nsecond observation");
671 assert_eq!(parsed.response_id, "resp_123");
672 assert_eq!(parsed.model, "gpt-5.6-2026-07-01");
673 assert_eq!(parsed.status, ImageAnalysisStatus::Completed);
674 assert_eq!(parsed.request_id.as_deref(), Some("req_vision"));
675 assert_eq!(
676 parsed.usage,
677 Some(ImageAnalysisUsage {
678 input_tokens: 40,
679 output_tokens: 12,
680 total_tokens: 52,
681 cached_input_tokens: Some(3),
682 cache_write_input_tokens: Some(2),
683 reasoning_output_tokens: Some(4),
684 })
685 );
686 }
687
688 #[test]
689 fn image_analysis_response_labels_valid_partial_text() {
690 let payload = json!({
691 "id": "resp_partial",
692 "status": "incomplete",
693 "incomplete_details": {"reason": "content_filter"},
694 "model": "gpt-5.6",
695 "output": [{
696 "type": "message",
697 "role": "assistant",
698 "content": [{"type": "output_text", "text": "visible partial result"}]
699 }]
700 });
701 let parsed = parse_image_analysis(&payload, None).unwrap();
702 assert_eq!(parsed.text, "visible partial result");
703 assert_eq!(
704 parsed.status,
705 ImageAnalysisStatus::Incomplete {
706 reason: Some("content_filter".into())
707 }
708 );
709 assert_eq!(parsed.usage, None);
710 }
711
712 #[test]
713 fn image_response_decodes_bytes_and_usage() {
714 let payload = json!({
715 "created": 1_721_000_000_u64,
716 "background": "opaque",
717 "output_format": "png",
718 "quality": "high",
719 "size": "2048x2048",
720 "data": [{"b64_json": "AQID"}],
721 "usage": {
722 "input_tokens": 10,
723 "output_tokens": 20,
724 "total_tokens": 30,
725 "input_tokens_details": {"text_tokens": 10, "image_tokens": 0},
726 "output_tokens_details": {"text_tokens": 0, "image_tokens": 20}
727 }
728 });
729 let parsed = parse_image_generation(&payload, ImageFormat::Png, None).unwrap();
730 assert_eq!(parsed.image.data, vec![1, 2, 3]);
731 assert_eq!(parsed.image.format, ImageFormat::Png);
732 assert_eq!(parsed.size.as_deref(), Some("2048x2048"));
733 assert_eq!(parsed.quality, Some(ImageQuality::High));
734 assert_eq!(
735 parsed.usage.unwrap().output_details.unwrap().image_tokens,
736 20
737 );
738 }
739
740 #[test]
741 fn gpt_image_payload_supports_flexible_dimensions() {
742 let mut request = ImageGenerationRequest::new("draw a quiet library");
743 request.size = ImageSize::dimensions(1536, 864).unwrap();
744 let payload = request.payload();
745 assert_eq!(payload["model"], "gpt-image-2");
746 assert_eq!(payload["size"], "1536x864");
747 }
748
749 #[test]
750 fn provider_errors_are_sanitized_and_keep_request_ids() {
751 let error = provider_error(
752 StatusCode::BAD_REQUEST,
753 br#"{"error":{"code":"moderation_blocked","message":"bad\nrequest"}}"#,
754 Some("req_456".into()),
755 );
756 assert!(matches!(
757 error,
758 Error::Provider {
759 status: 400,
760 code: Some(code),
761 message,
762 request_id: Some(request_id),
763 } if code == "moderation_blocked" && message == "bad request" && request_id == "req_456"
764 ));
765 }
766}