Skip to main content

audacity_sdk/
client.rs

1//! The main `Client` — fluent builder-style API mirroring aws-sdk-bedrockruntime.
2
3use std::time::{Duration, Instant};
4
5use rand::Rng;
6use serde_json::Value;
7
8use crate::config::Config;
9use crate::error::{parse_error, parse_retry_after, status_error};
10use crate::mapping::{build_request_body, parse_response};
11use crate::stream::{spawn_stream_driver, ConverseStreamOutputHandle};
12use crate::types::{
13    ConverseOutput, GenerateImageOutput, InferenceConfiguration, MediaResolution, Message,
14    SystemContentBlock, ToolConfiguration, UploadFileOutput,
15};
16use crate::Error;
17
18const USER_AGENT: &str = concat!("audacity-sdk-rust/", env!("CARGO_PKG_VERSION"));
19
20/// Default resumable-upload chunk size (spec §6): 8 MiB, a multiple of the
21/// protocol's required 256 KiB granularity.
22const DEFAULT_UPLOAD_CHUNK_SIZE: usize = 8 * 1024 * 1024;
23
24/// Max §6 recovery attempts (offset query + resume) without confirmed progress.
25const MAX_UPLOAD_RECOVERY_ATTEMPTS: u32 = 5;
26
27/// The Audacity SDK client.
28#[derive(Clone)]
29pub struct Client {
30    config: Config,
31    http: reqwest::Client,
32}
33
34impl Client {
35    /// Create a new client from an explicit [`Config`].
36    ///
37    /// No client-wide timeout is set: per spec §1 the configured timeout bounds
38    /// each `converse` attempt (including the body read) via a per-request
39    /// timeout, and a `converse_stream` attempt only up to the response
40    /// headers — the SSE body read is unbounded so long generations are never
41    /// killed mid-stream.
42    pub fn new(config: &Config) -> Result<Self, Error> {
43        let http = reqwest::Client::builder()
44            .build()
45            .map_err(|e| Error::sdk(format!("failed to build HTTP client: {e}")))?;
46        Ok(Self {
47            config: config.clone(),
48            http,
49        })
50    }
51
52    /// Create a client from the environment (`AUDACITY_API_KEY`, `AUDACITY_BASE_URL`).
53    /// Returns `Err(Error::MissingApiKey)` if no key is found.
54    pub fn from_env() -> Result<Self, Error> {
55        let config = Config::builder().build()?;
56        Self::new(&config)
57    }
58
59    /// Start building a `converse` request.
60    pub fn converse(&self) -> ConverseBuilder {
61        ConverseBuilder::new(self.clone())
62    }
63
64    /// Start building a `converse_stream` request.
65    pub fn converse_stream(&self) -> ConverseStreamBuilder {
66        ConverseStreamBuilder::new(self.clone())
67    }
68
69    /// Start building a file upload (spec §6) — for videos too large to send
70    /// inline. Returns an [`UploadFileOutput`] whose `uri` can be referenced
71    /// in [`VideoSource::Uri`](crate::types::VideoSource::Uri).
72    pub fn upload_file(&self) -> UploadFileBuilder {
73        UploadFileBuilder::new(self.clone())
74    }
75
76    /// Start building an image generation (spec §8) — a text prompt in,
77    /// generated image(s) out (signed URL or inline base64).
78    pub fn generate_image(&self) -> GenerateImageBuilder {
79        GenerateImageBuilder::new(self.clone())
80    }
81
82    // ── internal HTTP helpers ─────────────────────────────────────────────────
83
84    pub(crate) async fn send_converse(
85        &self,
86        input: &ConverseInput,
87    ) -> Result<ConverseOutput, Error> {
88        let body = build_request_body(input, false);
89        // Spec §3: latencyMs is measured from request start (before the first
90        // attempt) to the response being fully parsed.
91        let start = Instant::now();
92        let text = self.post_converse(&body).await?;
93        let raw: Value =
94            serde_json::from_str(&text).map_err(|e| Error::sdk(format!("JSON decode error: {e}")))?;
95        let latency = start.elapsed().as_millis() as u64;
96        parse_response(&raw, latency)
97    }
98
99    pub(crate) async fn send_converse_stream(
100        &self,
101        input: &ConverseInput,
102    ) -> Result<ConverseStreamOutputHandle, Error> {
103        let body = build_request_body(input, true);
104        let start = Instant::now();
105        let resp = self.post_converse_stream(&body).await?;
106
107        // Once we have the 200, never retry — spec §4
108        Ok(spawn_stream_driver(resp, start))
109    }
110
111    pub(crate) async fn send_upload_file(
112        &self,
113        data: Vec<u8>,
114        content_type: String,
115        chunk_size: usize,
116    ) -> Result<UploadFileOutput, Error> {
117        // Step 1: request an upload ticket — same auth/error/retry machinery
118        // as converse. The response is flat JSON (not enveloped).
119        let url = format!("{}/v1/files", self.config.base_url);
120        let body = serde_json::json!({
121            "content_type": content_type,
122            "size_bytes": data.len(),
123        });
124        let text = match self
125            .request_with_retries(&url, &body, RequestMode::Converse)
126            .await?
127        {
128            Attempted::Body(text) => text,
129            Attempted::Stream(_) => unreachable!("Converse mode always yields Body"),
130        };
131        let ticket: UploadFileOutput = serde_json::from_str(&text)
132            .map_err(|e| Error::sdk(format!("JSON decode error on /v1/files response: {e}")))?;
133
134        // Step 2: GCS resumable-session protocol (spec §6) — initiate a
135        // session at the presigned URL, then PUT the bytes in Content-Range
136        // chunks with bounded offset-query recovery. Errors map by HTTP
137        // status only (storage-provider bodies are XML, not gateway payloads).
138        let session_uri = self
139            .initiate_resumable_session(&ticket.upload_url, &content_type)
140            .await?;
141        self.upload_chunks(&session_uri, &data, chunk_size).await?;
142
143        Ok(ticket)
144    }
145
146    /// Spec §6 step 2a: `POST <upload_url>` with `x-goog-resumable: start`
147    /// and the Content-Type bound into the signature, an empty body, and no
148    /// Authorization header (the URL is self-authorizing). A 2xx response
149    /// carries the session URI in its `Location` header. Transient failures
150    /// (network, 5xx, 429) retry with the §4 backoff under the same bound as
151    /// chunk recovery; other non-2xx fail immediately, mapped by HTTP status.
152    async fn initiate_resumable_session(
153        &self,
154        upload_url: &str,
155        content_type: &str,
156    ) -> Result<String, Error> {
157        let mut attempt = 0u32;
158        loop {
159            let outcome = self
160                .http
161                .post(upload_url)
162                .header("x-goog-resumable", "start")
163                .header("Content-Type", content_type)
164                .header("User-Agent", USER_AGENT)
165                .timeout(self.config.timeout)
166                .send()
167                .await;
168
169            let (err, retry_after) = match outcome {
170                Ok(resp) if resp.status().is_success() => {
171                    return resp
172                        .headers()
173                        .get("location")
174                        .and_then(|v| v.to_str().ok())
175                        .map(str::to_owned)
176                        .ok_or_else(|| {
177                            Error::sdk(
178                                "resumable upload initiation response missing Location header",
179                            )
180                        });
181                }
182                Ok(resp) => {
183                    let status = resp.status().as_u16();
184                    let retry_after = header_retry_after(&resp);
185                    let transient = status == 429 || status >= 500;
186                    let body_text = resp.text().await.unwrap_or_default();
187                    let err = status_error(status, &body_text);
188                    if !transient {
189                        return Err(err);
190                    }
191                    (err, retry_after)
192                }
193                Err(e) => (
194                    Error::sdk_network(format!("network error initiating resumable upload: {e}")),
195                    None,
196                ),
197            };
198
199            attempt += 1;
200            if attempt > MAX_UPLOAD_RECOVERY_ATTEMPTS {
201                return Err(err);
202            }
203            backoff(attempt, retry_after).await;
204        }
205    }
206
207    /// Spec §6 steps 2b–2d: PUT the file to the session URI in order with
208    /// `Content-Range: bytes <first>-<last>/<total>` chunks. A 308 confirms
209    /// persisted bytes via its `Range: bytes=0-N` header (the next chunk
210    /// starts at `N+1`); 200/201 completes the upload. On a transient failure
211    /// (network, 5xx, 429) the confirmed offset is re-queried with
212    /// `Content-Range: bytes */<total>` and the upload resumes from there —
213    /// at most 5 recovery attempts with the §4 backoff, the counter resetting
214    /// on confirmed progress. Other 4xx fail immediately by HTTP status.
215    async fn upload_chunks(
216        &self,
217        session_uri: &str,
218        data: &[u8],
219        chunk_size: usize,
220    ) -> Result<(), Error> {
221        let total = data.len();
222        let mut offset = 0usize;
223        let mut failures = 0u32;
224        // When true the next request is an offset query rather than a chunk.
225        // An empty file finalizes with the query form (`bytes */0`).
226        let mut querying = total == 0;
227
228        loop {
229            let req = if querying {
230                self.http
231                    .put(session_uri)
232                    .header("Content-Range", format!("bytes */{total}"))
233            } else {
234                let end = (offset + chunk_size).min(total);
235                self.http
236                    .put(session_uri)
237                    .header(
238                        "Content-Range",
239                        format!("bytes {offset}-{}/{total}", end - 1),
240                    )
241                    .body(data[offset..end].to_vec())
242            };
243            let outcome = req
244                .header("User-Agent", USER_AGENT)
245                .timeout(self.config.timeout)
246                .send()
247                .await;
248
249            let (err, retry_after) = match outcome {
250                Ok(resp) if resp.status().is_success() => return Ok(()),
251                Ok(resp) if resp.status().as_u16() == 308 => {
252                    // Resume Incomplete: the Range header confirms how many
253                    // bytes are persisted; no Range means none are. A 308
254                    // showing less progress than sent just means the upload
255                    // continues from the confirmed offset.
256                    let confirmed = confirmed_offset(resp.headers());
257                    if confirmed > offset {
258                        failures = 0; // progress resets the recovery budget
259                    }
260                    offset = confirmed;
261                    querying = false;
262                    continue;
263                }
264                Ok(resp) => {
265                    let status = resp.status().as_u16();
266                    let retry_after = header_retry_after(&resp);
267                    let transient = status == 429 || status >= 500;
268                    let body_text = resp.text().await.unwrap_or_default();
269                    let err = status_error(status, &body_text);
270                    if !transient {
271                        return Err(err);
272                    }
273                    (err, retry_after)
274                }
275                Err(e) => (
276                    Error::sdk_network(format!("network error uploading file chunk: {e}")),
277                    None,
278                ),
279            };
280
281            failures += 1;
282            if failures > MAX_UPLOAD_RECOVERY_ATTEMPTS {
283                return Err(err);
284            }
285            backoff(failures, retry_after).await;
286            querying = true;
287        }
288    }
289
290    pub(crate) async fn send_generate_image(
291        &self,
292        body: &Value,
293    ) -> Result<GenerateImageOutput, Error> {
294        let url = format!("{}/v1/images/generations", self.config.base_url);
295        let text = match self
296            .request_with_retries(&url, body, RequestMode::Converse)
297            .await?
298        {
299            Attempted::Body(text) => text,
300            Attempted::Stream(_) => unreachable!("Converse mode always yields Body"),
301        };
302        // NOTE: the §1 defensive data-envelope unwrap is deliberately NOT
303        // applied here — `data` is a legitimate top-level key of the images
304        // response (the list of generated images).
305        serde_json::from_str(&text).map_err(|e| {
306            Error::sdk(format!(
307                "JSON decode error on /v1/images/generations response: {e}"
308            ))
309        })
310    }
311
312    /// Retried POST for `Converse` — resolves to the fully-read 200 body.
313    async fn post_converse(&self, body: &Value) -> Result<String, Error> {
314        let url = format!("{}/v1/chat/completions", self.config.base_url);
315        match self.request_with_retries(&url, body, RequestMode::Converse).await? {
316            Attempted::Body(text) => Ok(text),
317            Attempted::Stream(_) => unreachable!("Converse mode always yields Body"),
318        }
319    }
320
321    /// Retried POST for `ConverseStream` — resolves to the unread 200 response.
322    async fn post_converse_stream(&self, body: &Value) -> Result<reqwest::Response, Error> {
323        let url = format!("{}/v1/chat/completions", self.config.base_url);
324        match self.request_with_retries(&url, body, RequestMode::Stream).await? {
325            Attempted::Stream(resp) => Ok(resp),
326            Attempted::Body(_) => unreachable!("Stream mode always yields Stream"),
327        }
328    }
329
330    /// POST the request body with the spec §4 retry policy (attempts, jittered
331    /// backoff, `Retry-After`). Retryability is decided solely by
332    /// [`Error::is_retryable`].
333    ///
334    /// Timeout semantics (spec §1): a `Converse` attempt is bounded end to end
335    /// — including reading the response body, which happens inside this loop so
336    /// a network failure mid-body is still retryable. A `Stream` attempt is
337    /// bounded only until status + headers arrive; the SSE body is unbounded.
338    async fn request_with_retries(
339        &self,
340        url: &str,
341        body: &Value,
342        mode: RequestMode,
343    ) -> Result<Attempted, Error> {
344        let max_attempts = self.config.max_retries + 1;
345        let mut attempt = 0u32;
346
347        loop {
348            let accept = match mode {
349                RequestMode::Converse => "application/json",
350                RequestMode::Stream => "text/event-stream",
351            };
352            let req = self
353                .http
354                .post(url)
355                .header("Authorization", format!("Bearer {}", self.config.api_key))
356                .header("Content-Type", "application/json")
357                .header("Accept", accept)
358                .header("User-Agent", USER_AGENT)
359                .json(body);
360
361            let outcome: Result<reqwest::Response, Error> = match mode {
362                RequestMode::Converse => req
363                    .timeout(self.config.timeout)
364                    .send()
365                    .await
366                    .map_err(|e| Error::sdk_network(format!("network error: {e}"))),
367                RequestMode::Stream => {
368                    match tokio::time::timeout(self.config.timeout, req.send()).await {
369                        Ok(Ok(resp)) => Ok(resp),
370                        Ok(Err(e)) => Err(Error::sdk_network(format!("network error: {e}"))),
371                        Err(_) => Err(Error::sdk_network(
372                            "timed out waiting for response headers",
373                        )),
374                    }
375                }
376            };
377
378            let err = match outcome {
379                Ok(resp) if resp.status().as_u16() == 200 => match mode {
380                    RequestMode::Stream => return Ok(Attempted::Stream(resp)),
381                    RequestMode::Converse => match resp.text().await {
382                        Ok(text) => return Ok(Attempted::Body(text)),
383                        Err(e) => {
384                            Error::sdk_network(format!("network error reading response body: {e}"))
385                        }
386                    },
387                },
388                Ok(resp) => {
389                    let status = resp.status().as_u16();
390                    let retry_after = resp
391                        .headers()
392                        .get("retry-after")
393                        .and_then(|v| v.to_str().ok())
394                        .and_then(parse_retry_after);
395                    let body_text = resp.text().await.unwrap_or_default();
396                    parse_error(status, &body_text, retry_after)
397                }
398                Err(e) => e,
399            };
400
401            attempt += 1;
402            if attempt >= max_attempts || !err.is_retryable() {
403                return Err(err);
404            }
405            backoff(attempt, err.retry_after_seconds()).await;
406        }
407    }
408}
409
410/// Which operation a retried request is for (drives timeout + body handling).
411#[derive(Clone, Copy)]
412enum RequestMode {
413    Converse,
414    Stream,
415}
416
417/// Successful outcome of [`Client::request_with_retries`].
418enum Attempted {
419    /// Fully read 200 response body (`Converse`).
420    Body(String),
421    /// Unread 200 response, headers received (`Stream`).
422    Stream(reqwest::Response),
423}
424
425/// Parse the confirmed byte count from a 308 `Range: bytes=0-N` header —
426/// `N+1` bytes are persisted. A missing/unparsable Range means none are.
427fn confirmed_offset(headers: &reqwest::header::HeaderMap) -> usize {
428    headers
429        .get("range")
430        .and_then(|v| v.to_str().ok())
431        .and_then(|s| s.trim().strip_prefix("bytes=0-"))
432        .and_then(|n| n.parse::<usize>().ok())
433        .map(|n| n + 1)
434        .unwrap_or(0)
435}
436
437/// Extract a `Retry-After` header value in seconds, if present.
438fn header_retry_after(resp: &reqwest::Response) -> Option<f64> {
439    resp.headers()
440        .get("retry-after")
441        .and_then(|v| v.to_str().ok())
442        .and_then(parse_retry_after)
443}
444
445/// Jittered exponential backoff per spec: `random(0, min(20s, 0.5s * 2^attempt))`.
446/// If `retry_after` is set, sleep at least that long.
447async fn backoff(attempt: u32, retry_after: Option<f64>) {
448    let cap = 20.0_f64;
449    let base = 0.5_f64 * 2f64.powi(attempt as i32);
450    let ceiling = base.min(cap);
451    let jittered: f64 = rand::thread_rng().gen_range(0.0..=ceiling);
452    let sleep_secs = if let Some(ra) = retry_after {
453        jittered.max(ra)
454    } else {
455        jittered
456    };
457    tokio::time::sleep(Duration::from_secs_f64(sleep_secs)).await;
458}
459
460// ── Input type ────────────────────────────────────────────────────────────────
461
462/// The canonical input for both `converse` and `converse_stream`.
463#[derive(Default)]
464pub struct ConverseInput {
465    pub model_id: String,
466    pub messages: Vec<Message>,
467    pub system: Vec<SystemContentBlock>,
468    pub inference_config: Option<InferenceConfiguration>,
469    pub media_resolution: Option<MediaResolution>,
470    pub tool_config: Option<ToolConfiguration>,
471    pub additional_model_request_fields: Option<Value>,
472}
473
474impl ConverseInput {
475    /// Validate the required fields before sending (shared by both builders).
476    fn validate(&self) -> Result<(), Error> {
477        if self.model_id.is_empty() {
478            return Err(Error::client_validation("model_id is required"));
479        }
480        if self.messages.is_empty() {
481            return Err(Error::client_validation("messages must not be empty"));
482        }
483        if let Some(v) = &self.additional_model_request_fields {
484            if !v.is_object() {
485                return Err(Error::client_validation(
486                    "additional_model_request_fields must be a JSON object",
487                ));
488            }
489        }
490        Ok(())
491    }
492}
493
494// ── Fluent builders ───────────────────────────────────────────────────────────
495
496/// Generates the shared constructor + input setters for a fluent builder
497/// wrapping [`ConverseInput`]. Both builder types stay public (aws-sdk parity);
498/// only the method bodies are defined once here.
499macro_rules! impl_converse_input_builder {
500    ($builder:ident) => {
501        impl $builder {
502            fn new(client: Client) -> Self {
503                Self {
504                    client,
505                    input: ConverseInput::default(),
506                }
507            }
508
509            pub fn model_id(mut self, id: impl Into<String>) -> Self {
510                self.input.model_id = id.into();
511                self
512            }
513
514            pub fn messages(mut self, msg: Message) -> Self {
515                self.input.messages.push(msg);
516                self
517            }
518
519            pub fn set_messages(mut self, msgs: Vec<Message>) -> Self {
520                self.input.messages = msgs;
521                self
522            }
523
524            pub fn system(mut self, block: SystemContentBlock) -> Self {
525                self.input.system.push(block);
526                self
527            }
528
529            pub fn set_system(mut self, blocks: Vec<SystemContentBlock>) -> Self {
530                self.input.system = blocks;
531                self
532            }
533
534            pub fn inference_config(mut self, cfg: InferenceConfiguration) -> Self {
535                self.input.inference_config = Some(cfg);
536                self
537            }
538
539            pub fn media_resolution(mut self, v: MediaResolution) -> Self {
540                self.input.media_resolution = Some(v);
541                self
542            }
543
544            pub fn tool_config(mut self, tc: ToolConfiguration) -> Self {
545                self.input.tool_config = Some(tc);
546                self
547            }
548
549            pub fn additional_model_request_fields(mut self, v: Value) -> Self {
550                self.input.additional_model_request_fields = Some(v);
551                self
552            }
553        }
554    };
555}
556
557/// Fluent builder for `converse`.
558pub struct ConverseBuilder {
559    client: Client,
560    input: ConverseInput,
561}
562
563impl_converse_input_builder!(ConverseBuilder);
564
565impl ConverseBuilder {
566    pub async fn send(self) -> Result<ConverseOutput, Error> {
567        self.input.validate()?;
568        self.client.send_converse(&self.input).await
569    }
570}
571
572/// Fluent builder for `converse_stream`.
573pub struct ConverseStreamBuilder {
574    client: Client,
575    input: ConverseInput,
576}
577
578impl_converse_input_builder!(ConverseStreamBuilder);
579
580impl ConverseStreamBuilder {
581    pub async fn send(self) -> Result<ConverseStreamOutputHandle, Error> {
582        self.input.validate()?;
583        self.client.send_converse_stream(&self.input).await
584    }
585}
586
587/// Fluent builder for `upload_file` (spec §6).
588pub struct UploadFileBuilder {
589    client: Client,
590    data: Option<Vec<u8>>,
591    content_type: Option<String>,
592    chunk_size: usize,
593}
594
595impl UploadFileBuilder {
596    fn new(client: Client) -> Self {
597        Self {
598            client,
599            data: None,
600            content_type: None,
601            chunk_size: DEFAULT_UPLOAD_CHUNK_SIZE,
602        }
603    }
604
605    /// The raw file bytes to upload.
606    pub fn data(mut self, data: impl Into<Vec<u8>>) -> Self {
607        self.data = Some(data.into());
608        self
609    }
610
611    /// The MIME type of the file — one of the spec §3 video MIME types,
612    /// e.g. `"video/mp4"`.
613    pub fn content_type(mut self, content_type: impl Into<String>) -> Self {
614        self.content_type = Some(content_type.into());
615        self
616    }
617
618    /// Override the resumable-upload chunk size in bytes (test hook). Must be
619    /// a multiple of 256 KiB (262144) per the GCS resumable protocol.
620    #[doc(hidden)]
621    pub fn chunk_size(mut self, n: usize) -> Self {
622        self.chunk_size = n;
623        self
624    }
625
626    pub async fn send(self) -> Result<UploadFileOutput, Error> {
627        let data = self
628            .data
629            .ok_or_else(|| Error::client_validation("upload_file data is required"))?;
630        let content_type = self
631            .content_type
632            .ok_or_else(|| Error::client_validation("upload_file content_type is required"))?;
633        self.client
634            .send_upload_file(data, content_type, self.chunk_size)
635            .await
636    }
637}
638
639/// Fluent builder for `generate_image` (spec §8).
640pub struct GenerateImageBuilder {
641    client: Client,
642    model: Option<String>,
643    prompt: Option<String>,
644    n: Option<u32>,
645    size: Option<String>,
646    quality: Option<String>,
647    response_format: Option<String>,
648    user: Option<String>,
649}
650
651impl GenerateImageBuilder {
652    fn new(client: Client) -> Self {
653        Self {
654            client,
655            model: None,
656            prompt: None,
657            n: None,
658            size: None,
659            quality: None,
660            response_format: None,
661            user: None,
662        }
663    }
664
665    /// The image model id (e.g. `"gpt-image-1"`, `"dall-e-3"`, `"imagen-4"`).
666    /// Required.
667    pub fn model(mut self, model: impl Into<String>) -> Self {
668        self.model = Some(model.into());
669        self
670    }
671
672    /// Text description of the desired image(s); 1–32000 characters. Required.
673    pub fn prompt(mut self, prompt: impl Into<String>) -> Self {
674        self.prompt = Some(prompt.into());
675        self
676    }
677
678    /// Number of images to generate (1–10, model-dependent).
679    pub fn n(mut self, n: u32) -> Self {
680        self.n = Some(n);
681        self
682    }
683
684    /// Dimensions as a `"WxH"` string (e.g. `"1024x1024"`); supported values
685    /// differ per model.
686    pub fn size(mut self, size: impl Into<String>) -> Self {
687        self.size = Some(size.into());
688        self
689    }
690
691    /// Provider-specific quality tier (e.g. `"standard"`, `"hd"`).
692    pub fn quality(mut self, quality: impl Into<String>) -> Self {
693        self.quality = Some(quality.into());
694        self
695    }
696
697    /// Output delivery: `"url"` (default, signed download link expiring after
698    /// ~24 h) or `"b64_json"` (inline base64 bytes).
699    pub fn response_format(mut self, response_format: impl Into<String>) -> Self {
700        self.response_format = Some(response_format.into());
701        self
702    }
703
704    /// End-user identifier forwarded to the provider.
705    pub fn user(mut self, user: impl Into<String>) -> Self {
706        self.user = Some(user.into());
707        self
708    }
709
710    pub async fn send(self) -> Result<GenerateImageOutput, Error> {
711        let model = self
712            .model
713            .filter(|m| !m.is_empty())
714            .ok_or_else(|| Error::client_validation("generate_image model is required"))?;
715        let prompt = self
716            .prompt
717            .filter(|p| !p.is_empty())
718            .ok_or_else(|| Error::client_validation("generate_image prompt is required"))?;
719
720        // Omit absent optionals entirely (never send nulls).
721        let mut body = serde_json::Map::new();
722        body.insert("model".into(), Value::String(model));
723        body.insert("prompt".into(), Value::String(prompt));
724        if let Some(n) = self.n {
725            body.insert("n".into(), Value::from(n));
726        }
727        if let Some(size) = self.size {
728            body.insert("size".into(), Value::String(size));
729        }
730        if let Some(quality) = self.quality {
731            body.insert("quality".into(), Value::String(quality));
732        }
733        if let Some(response_format) = self.response_format {
734            body.insert("response_format".into(), Value::String(response_format));
735        }
736        if let Some(user) = self.user {
737            body.insert("user".into(), Value::String(user));
738        }
739
740        self.client.send_generate_image(&Value::Object(body)).await
741    }
742}