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};
10use crate::mapping::{build_request_body, parse_response};
11use crate::stream::{spawn_stream_driver, ConverseStreamOutputHandle};
12use crate::types::{
13    ConverseOutput, InferenceConfiguration, Message, SystemContentBlock, ToolConfiguration,
14};
15use crate::Error;
16
17const USER_AGENT: &str = concat!("audacity-sdk-rust/", env!("CARGO_PKG_VERSION"));
18
19/// The Audacity SDK client.
20#[derive(Clone)]
21pub struct Client {
22    config: Config,
23    http: reqwest::Client,
24}
25
26impl Client {
27    /// Create a new client from an explicit [`Config`].
28    ///
29    /// No client-wide timeout is set: per spec §1 the configured timeout bounds
30    /// each `converse` attempt (including the body read) via a per-request
31    /// timeout, and a `converse_stream` attempt only up to the response
32    /// headers — the SSE body read is unbounded so long generations are never
33    /// killed mid-stream.
34    pub fn new(config: &Config) -> Result<Self, Error> {
35        let http = reqwest::Client::builder()
36            .build()
37            .map_err(|e| Error::sdk(format!("failed to build HTTP client: {e}")))?;
38        Ok(Self {
39            config: config.clone(),
40            http,
41        })
42    }
43
44    /// Create a client from the environment (`AUDACITY_API_KEY`, `AUDACITY_BASE_URL`).
45    /// Returns `Err(Error::MissingApiKey)` if no key is found.
46    pub fn from_env() -> Result<Self, Error> {
47        let config = Config::builder().build()?;
48        Self::new(&config)
49    }
50
51    /// Start building a `converse` request.
52    pub fn converse(&self) -> ConverseBuilder {
53        ConverseBuilder::new(self.clone())
54    }
55
56    /// Start building a `converse_stream` request.
57    pub fn converse_stream(&self) -> ConverseStreamBuilder {
58        ConverseStreamBuilder::new(self.clone())
59    }
60
61    // ── internal HTTP helpers ─────────────────────────────────────────────────
62
63    pub(crate) async fn send_converse(
64        &self,
65        input: &ConverseInput,
66    ) -> Result<ConverseOutput, Error> {
67        let body = build_request_body(input, false);
68        // Spec §3: latencyMs is measured from request start (before the first
69        // attempt) to the response being fully parsed.
70        let start = Instant::now();
71        let text = self.post_converse(&body).await?;
72        let raw: Value =
73            serde_json::from_str(&text).map_err(|e| Error::sdk(format!("JSON decode error: {e}")))?;
74        let latency = start.elapsed().as_millis() as u64;
75        parse_response(&raw, latency)
76    }
77
78    pub(crate) async fn send_converse_stream(
79        &self,
80        input: &ConverseInput,
81    ) -> Result<ConverseStreamOutputHandle, Error> {
82        let body = build_request_body(input, true);
83        let start = Instant::now();
84        let resp = self.post_converse_stream(&body).await?;
85
86        // Once we have the 200, never retry — spec §4
87        Ok(spawn_stream_driver(resp, start))
88    }
89
90    /// Retried POST for `Converse` — resolves to the fully-read 200 body.
91    async fn post_converse(&self, body: &Value) -> Result<String, Error> {
92        match self.request_with_retries(body, RequestMode::Converse).await? {
93            Attempted::Body(text) => Ok(text),
94            Attempted::Stream(_) => unreachable!("Converse mode always yields Body"),
95        }
96    }
97
98    /// Retried POST for `ConverseStream` — resolves to the unread 200 response.
99    async fn post_converse_stream(&self, body: &Value) -> Result<reqwest::Response, Error> {
100        match self.request_with_retries(body, RequestMode::Stream).await? {
101            Attempted::Stream(resp) => Ok(resp),
102            Attempted::Body(_) => unreachable!("Stream mode always yields Stream"),
103        }
104    }
105
106    /// POST the request body with the spec §4 retry policy (attempts, jittered
107    /// backoff, `Retry-After`). Retryability is decided solely by
108    /// [`Error::is_retryable`].
109    ///
110    /// Timeout semantics (spec §1): a `Converse` attempt is bounded end to end
111    /// — including reading the response body, which happens inside this loop so
112    /// a network failure mid-body is still retryable. A `Stream` attempt is
113    /// bounded only until status + headers arrive; the SSE body is unbounded.
114    async fn request_with_retries(&self, body: &Value, mode: RequestMode) -> Result<Attempted, Error> {
115        let url = format!("{}/v1/chat/completions", self.config.base_url);
116        let max_attempts = self.config.max_retries + 1;
117        let mut attempt = 0u32;
118
119        loop {
120            let accept = match mode {
121                RequestMode::Converse => "application/json",
122                RequestMode::Stream => "text/event-stream",
123            };
124            let req = self
125                .http
126                .post(&url)
127                .header("Authorization", format!("Bearer {}", self.config.api_key))
128                .header("Content-Type", "application/json")
129                .header("Accept", accept)
130                .header("User-Agent", USER_AGENT)
131                .json(body);
132
133            let outcome: Result<reqwest::Response, Error> = match mode {
134                RequestMode::Converse => req
135                    .timeout(self.config.timeout)
136                    .send()
137                    .await
138                    .map_err(|e| Error::sdk_network(format!("network error: {e}"))),
139                RequestMode::Stream => {
140                    match tokio::time::timeout(self.config.timeout, req.send()).await {
141                        Ok(Ok(resp)) => Ok(resp),
142                        Ok(Err(e)) => Err(Error::sdk_network(format!("network error: {e}"))),
143                        Err(_) => Err(Error::sdk_network(
144                            "timed out waiting for response headers",
145                        )),
146                    }
147                }
148            };
149
150            let err = match outcome {
151                Ok(resp) if resp.status().as_u16() == 200 => match mode {
152                    RequestMode::Stream => return Ok(Attempted::Stream(resp)),
153                    RequestMode::Converse => match resp.text().await {
154                        Ok(text) => return Ok(Attempted::Body(text)),
155                        Err(e) => {
156                            Error::sdk_network(format!("network error reading response body: {e}"))
157                        }
158                    },
159                },
160                Ok(resp) => {
161                    let status = resp.status().as_u16();
162                    let retry_after = resp
163                        .headers()
164                        .get("retry-after")
165                        .and_then(|v| v.to_str().ok())
166                        .and_then(parse_retry_after);
167                    let body_text = resp.text().await.unwrap_or_default();
168                    parse_error(status, &body_text, retry_after)
169                }
170                Err(e) => e,
171            };
172
173            attempt += 1;
174            if attempt >= max_attempts || !err.is_retryable() {
175                return Err(err);
176            }
177            backoff(attempt, err.retry_after_seconds()).await;
178        }
179    }
180}
181
182/// Which operation a retried request is for (drives timeout + body handling).
183#[derive(Clone, Copy)]
184enum RequestMode {
185    Converse,
186    Stream,
187}
188
189/// Successful outcome of [`Client::request_with_retries`].
190enum Attempted {
191    /// Fully read 200 response body (`Converse`).
192    Body(String),
193    /// Unread 200 response, headers received (`Stream`).
194    Stream(reqwest::Response),
195}
196
197/// Jittered exponential backoff per spec: `random(0, min(20s, 0.5s * 2^attempt))`.
198/// If `retry_after` is set, sleep at least that long.
199async fn backoff(attempt: u32, retry_after: Option<f64>) {
200    let cap = 20.0_f64;
201    let base = 0.5_f64 * 2f64.powi(attempt as i32);
202    let ceiling = base.min(cap);
203    let jittered: f64 = rand::thread_rng().gen_range(0.0..=ceiling);
204    let sleep_secs = if let Some(ra) = retry_after {
205        jittered.max(ra)
206    } else {
207        jittered
208    };
209    tokio::time::sleep(Duration::from_secs_f64(sleep_secs)).await;
210}
211
212// ── Input type ────────────────────────────────────────────────────────────────
213
214/// The canonical input for both `converse` and `converse_stream`.
215#[derive(Default)]
216pub struct ConverseInput {
217    pub model_id: String,
218    pub messages: Vec<Message>,
219    pub system: Vec<SystemContentBlock>,
220    pub inference_config: Option<InferenceConfiguration>,
221    pub tool_config: Option<ToolConfiguration>,
222    pub additional_model_request_fields: Option<Value>,
223}
224
225impl ConverseInput {
226    /// Validate the required fields before sending (shared by both builders).
227    fn validate(&self) -> Result<(), Error> {
228        if self.model_id.is_empty() {
229            return Err(Error::client_validation("model_id is required"));
230        }
231        if self.messages.is_empty() {
232            return Err(Error::client_validation("messages must not be empty"));
233        }
234        if let Some(v) = &self.additional_model_request_fields {
235            if !v.is_object() {
236                return Err(Error::client_validation(
237                    "additional_model_request_fields must be a JSON object",
238                ));
239            }
240        }
241        Ok(())
242    }
243}
244
245// ── Fluent builders ───────────────────────────────────────────────────────────
246
247/// Generates the shared constructor + input setters for a fluent builder
248/// wrapping [`ConverseInput`]. Both builder types stay public (aws-sdk parity);
249/// only the method bodies are defined once here.
250macro_rules! impl_converse_input_builder {
251    ($builder:ident) => {
252        impl $builder {
253            fn new(client: Client) -> Self {
254                Self {
255                    client,
256                    input: ConverseInput::default(),
257                }
258            }
259
260            pub fn model_id(mut self, id: impl Into<String>) -> Self {
261                self.input.model_id = id.into();
262                self
263            }
264
265            pub fn messages(mut self, msg: Message) -> Self {
266                self.input.messages.push(msg);
267                self
268            }
269
270            pub fn set_messages(mut self, msgs: Vec<Message>) -> Self {
271                self.input.messages = msgs;
272                self
273            }
274
275            pub fn system(mut self, block: SystemContentBlock) -> Self {
276                self.input.system.push(block);
277                self
278            }
279
280            pub fn set_system(mut self, blocks: Vec<SystemContentBlock>) -> Self {
281                self.input.system = blocks;
282                self
283            }
284
285            pub fn inference_config(mut self, cfg: InferenceConfiguration) -> Self {
286                self.input.inference_config = Some(cfg);
287                self
288            }
289
290            pub fn tool_config(mut self, tc: ToolConfiguration) -> Self {
291                self.input.tool_config = Some(tc);
292                self
293            }
294
295            pub fn additional_model_request_fields(mut self, v: Value) -> Self {
296                self.input.additional_model_request_fields = Some(v);
297                self
298            }
299        }
300    };
301}
302
303/// Fluent builder for `converse`.
304pub struct ConverseBuilder {
305    client: Client,
306    input: ConverseInput,
307}
308
309impl_converse_input_builder!(ConverseBuilder);
310
311impl ConverseBuilder {
312    pub async fn send(self) -> Result<ConverseOutput, Error> {
313        self.input.validate()?;
314        self.client.send_converse(&self.input).await
315    }
316}
317
318/// Fluent builder for `converse_stream`.
319pub struct ConverseStreamBuilder {
320    client: Client,
321    input: ConverseInput,
322}
323
324impl_converse_input_builder!(ConverseStreamBuilder);
325
326impl ConverseStreamBuilder {
327    pub async fn send(self) -> Result<ConverseStreamOutputHandle, Error> {
328        self.input.validate()?;
329        self.client.send_converse_stream(&self.input).await
330    }
331}