Skip to main content

kcode_openai_api/
api.rs

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/// Cloneable asynchronous OpenAI client for transcription, image analysis, and image generation.
58#[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    /// Opens a client and retains the supplied OpenAI API key in memory.
80    ///
81    /// Clones share the key and HTTP connection pool. Dropping the last clone
82    /// discards the retained key with best-effort zeroization.
83    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    /// Transcribes one in-memory recording with `gpt-4o-transcribe`.
119    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    /// Analyzes one in-memory image with the fixed Responses API model.
152    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    /// Generates one image with the latest GPT Image model, `gpt-image-2`.
165    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    /// Modifies or combines reference images with `gpt-image-2`.
179    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    /// Executes one stateless text/tool turn with an exact OpenAI model.
223    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    /// Retrieves metadata for an exact OpenAI model identifier.
237    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}