Skip to main content

rig_core/providers/
azure.rs

1//! Azure OpenAI API client and Rig integration
2//!
3//! # Example
4//! ```no_run
5//! use rig_core::providers::azure;
6//! use rig_core::client::CompletionClient;
7//!
8//! # fn run() -> Result<(), Box<dyn std::error::Error>> {
9//! let client = azure::Client::builder()
10//!     .api_key("test")
11//!     .azure_endpoint("test".to_string()) // add your endpoint here!
12//!     .build()?;
13//!
14//! let gpt4o = client.completion_model(azure::GPT_4O);
15//! # Ok(())
16//! # }
17//! ```
18//!
19//! ## Authentication
20//! The authentication type used for the `azure` module is [`AzureOpenAIAuth`].
21//!
22//! By default, using a type that implements `Into<String>` as the input for the client builder will turn the type into a bearer auth token.
23//! If you want to use an API key, you need to use the type specifically.
24
25use std::fmt::Debug;
26
27use super::openai::TranscriptionResponse;
28use crate::client::{
29    self, ApiKey, Capabilities, Capable, DebugExt, Nothing, Provider, ProviderBuilder,
30    ProviderClient,
31};
32use crate::completion::GetTokenUsage;
33use crate::http_client::multipart::Part;
34use crate::http_client::{self, HttpClientExt, MultipartForm, bearer_auth_header};
35use crate::transcription::TranscriptionError;
36use crate::{
37    embeddings::{self, EmbeddingError},
38    providers::openai,
39    transcription::{self},
40};
41use bytes::Bytes;
42use serde::Deserialize;
43use serde_json::json;
44// ================================================================
45// Main Azure OpenAI Client
46// ================================================================
47
48const DEFAULT_API_VERSION: &str = "2024-10-21";
49
50#[derive(Debug, Clone)]
51pub struct AzureExt {
52    endpoint: String,
53    api_version: String,
54}
55
56impl DebugExt for AzureExt {
57    fn fields(&self) -> impl Iterator<Item = (&'static str, &dyn std::fmt::Debug)> {
58        [
59            ("endpoint", (&self.endpoint as &dyn Debug)),
60            ("api_version", (&self.api_version as &dyn Debug)),
61        ]
62        .into_iter()
63    }
64}
65
66// TODO: @FayCarsons - this should be a type-safe builder,
67// but that would require extending the `ProviderBuilder`
68// to have some notion of complete vs incomplete states in a
69// given extension builder
70#[derive(Debug, Clone)]
71pub struct AzureExtBuilder {
72    endpoint: Option<String>,
73    api_version: String,
74}
75
76impl Default for AzureExtBuilder {
77    fn default() -> Self {
78        Self {
79            endpoint: None,
80            api_version: DEFAULT_API_VERSION.into(),
81        }
82    }
83}
84
85pub type Client<H = reqwest::Client> = client::Client<AzureExt, H>;
86pub type ClientBuilder<H = crate::markers::Missing> =
87    client::ClientBuilder<AzureExtBuilder, AzureOpenAIAuth, H>;
88
89impl Provider for AzureExt {
90    type Builder = AzureExtBuilder;
91
92    /// Verifying Azure auth without consuming tokens is not supported
93    const VERIFY_PATH: &'static str = "";
94}
95
96impl<H> Capabilities<H> for AzureExt {
97    type Completion = Capable<CompletionModel<H>>;
98    type Embeddings = Capable<EmbeddingModel<H>>;
99    type Transcription = Capable<TranscriptionModel<H>>;
100    type ModelListing = Nothing;
101    #[cfg(feature = "image")]
102    type ImageGeneration = Nothing;
103    #[cfg(feature = "audio")]
104    type AudioGeneration = Capable<AudioGenerationModel<H>>;
105    type Rerank = Nothing;
106}
107
108impl ProviderBuilder for AzureExtBuilder {
109    type Extension<H>
110        = AzureExt
111    where
112        H: HttpClientExt;
113    type ApiKey = AzureOpenAIAuth;
114
115    const BASE_URL: &'static str = "";
116
117    fn build<H>(
118        builder: &client::ClientBuilder<Self, Self::ApiKey, H>,
119    ) -> http_client::Result<Self::Extension<H>>
120    where
121        H: HttpClientExt,
122    {
123        let AzureExtBuilder {
124            endpoint,
125            api_version,
126            ..
127        } = builder.ext().clone();
128
129        match endpoint {
130            Some(endpoint) => Ok(AzureExt {
131                endpoint,
132                api_version,
133            }),
134            None => Err(http_client::Error::Instance(
135                "Azure client must be provided an endpoint prior to building".into(),
136            )),
137        }
138    }
139
140    fn finish<H>(
141        &self,
142        mut builder: client::ClientBuilder<Self, Self::ApiKey, H>,
143    ) -> http_client::Result<client::ClientBuilder<Self, Self::ApiKey, H>> {
144        use AzureOpenAIAuth::*;
145
146        let auth = builder.get_api_key().clone();
147
148        match auth {
149            Token(token) => bearer_auth_header(builder.headers_mut(), token.as_str())?,
150            ApiKey(key) => {
151                let k = http::HeaderName::from_static("api-key");
152                let v = http::HeaderValue::from_str(key.as_str())?;
153
154                builder.headers_mut().insert(k, v);
155            }
156        }
157
158        Ok(builder)
159    }
160}
161
162impl<H> ClientBuilder<H> {
163    /// API version to use (e.g., "2024-10-21" for GA, "2024-10-01-preview" for preview)
164    pub fn api_version(mut self, api_version: &str) -> Self {
165        self.ext_mut().api_version = api_version.into();
166
167        self
168    }
169}
170
171impl<H> client::ClientBuilder<AzureExtBuilder, AzureOpenAIAuth, H> {
172    /// Azure OpenAI endpoint URL, for example: https://{your-resource-name}.openai.azure.com
173    pub fn azure_endpoint(self, endpoint: String) -> ClientBuilder<H> {
174        self.over_ext(|AzureExtBuilder { api_version, .. }| AzureExtBuilder {
175            endpoint: Some(endpoint),
176            api_version,
177        })
178    }
179}
180
181/// The authentication type for Azure OpenAI. Can either be an API key or a token.
182/// String types will automatically be coerced to a bearer auth token by default.
183#[derive(Clone)]
184pub enum AzureOpenAIAuth {
185    ApiKey(String),
186    Token(String),
187}
188
189impl ApiKey for AzureOpenAIAuth {}
190
191impl std::fmt::Debug for AzureOpenAIAuth {
192    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
193        match self {
194            Self::ApiKey(_) => write!(f, "API key <REDACTED>"),
195            Self::Token(_) => write!(f, "Token <REDACTED>"),
196        }
197    }
198}
199
200impl<S> From<S> for AzureOpenAIAuth
201where
202    S: Into<String>,
203{
204    fn from(token: S) -> Self {
205        AzureOpenAIAuth::Token(token.into())
206    }
207}
208
209impl<T> Client<T>
210where
211    T: HttpClientExt,
212{
213    fn endpoint(&self) -> &str {
214        &self.ext().endpoint
215    }
216
217    fn api_version(&self) -> &str {
218        &self.ext().api_version
219    }
220
221    fn post_embedding(&self, deployment_id: &str) -> http_client::Result<http_client::Builder> {
222        let url = format!(
223            "{}/openai/deployments/{}/embeddings?api-version={}",
224            self.endpoint(),
225            deployment_id.trim_start_matches('/'),
226            self.api_version()
227        );
228
229        self.post(&url)
230    }
231
232    #[cfg(feature = "audio")]
233    fn post_audio_generation(
234        &self,
235        deployment_id: &str,
236    ) -> http_client::Result<http_client::Builder> {
237        let url = format!(
238            "{}/openai/deployments/{}/audio/speech?api-version={}",
239            self.endpoint(),
240            deployment_id.trim_start_matches('/'),
241            self.api_version()
242        );
243
244        self.post(url)
245    }
246
247    fn post_transcription(&self, deployment_id: &str) -> http_client::Result<http_client::Builder> {
248        let url = format!(
249            "{}/openai/deployments/{}/audio/translations?api-version={}",
250            self.endpoint(),
251            deployment_id.trim_start_matches('/'),
252            self.api_version()
253        );
254
255        self.post(&url)
256    }
257
258    #[cfg(feature = "image")]
259    fn post_image_generation(
260        &self,
261        deployment_id: &str,
262    ) -> http_client::Result<http_client::Builder> {
263        let url = format!(
264            "{}/openai/deployments/{}/images/generations?api-version={}",
265            self.endpoint(),
266            deployment_id.trim_start_matches('/'),
267            self.api_version()
268        );
269
270        self.post(&url)
271    }
272}
273
274pub struct AzureOpenAIClientParams {
275    api_key: String,
276    version: String,
277    header: String,
278}
279
280impl ProviderClient for Client {
281    type Input = AzureOpenAIClientParams;
282    type Error = crate::client::ProviderClientError;
283
284    /// Create a new Azure OpenAI client from the `AZURE_API_KEY` or `AZURE_TOKEN`, `AZURE_API_VERSION`, and `AZURE_ENDPOINT` environment variables.
285    fn from_env() -> Result<Self, Self::Error> {
286        let auth = if let Some(api_key) = crate::client::optional_env_var("AZURE_API_KEY")? {
287            AzureOpenAIAuth::ApiKey(api_key)
288        } else if let Some(token) = crate::client::optional_env_var("AZURE_TOKEN")? {
289            AzureOpenAIAuth::Token(token)
290        } else {
291            return Err(crate::client::ProviderClientError::InvalidConfiguration(
292                "either `AZURE_API_KEY` or `AZURE_TOKEN` must be set",
293            ));
294        };
295
296        let api_version = crate::client::required_env_var("AZURE_API_VERSION")?;
297        let azure_endpoint = crate::client::required_env_var("AZURE_ENDPOINT")?;
298
299        Self::builder()
300            .api_key(auth)
301            .azure_endpoint(azure_endpoint)
302            .api_version(&api_version)
303            .build()
304            .map_err(Into::into)
305    }
306
307    fn from_val(
308        AzureOpenAIClientParams {
309            api_key,
310            version,
311            header,
312        }: Self::Input,
313    ) -> Result<Self, Self::Error> {
314        let auth = AzureOpenAIAuth::ApiKey(api_key.to_string());
315
316        Self::builder()
317            .api_key(auth)
318            .azure_endpoint(header)
319            .api_version(&version)
320            .build()
321            .map_err(Into::into)
322    }
323}
324
325#[derive(Debug, Deserialize)]
326struct ApiErrorResponse {
327    message: String,
328}
329
330#[derive(Debug, Deserialize)]
331#[serde(untagged)]
332enum ApiResponse<T> {
333    Ok(T),
334    Err(ApiErrorResponse),
335}
336
337// ================================================================
338// Azure OpenAI Embedding API
339// ================================================================
340
341/// `text-embedding-3-large` embedding model
342pub const TEXT_EMBEDDING_3_LARGE: &str = "text-embedding-3-large";
343/// `text-embedding-3-small` embedding model
344pub const TEXT_EMBEDDING_3_SMALL: &str = "text-embedding-3-small";
345/// `text-embedding-ada-002` embedding model
346pub const TEXT_EMBEDDING_ADA_002: &str = "text-embedding-ada-002";
347
348fn model_dimensions_from_identifier(identifier: &str) -> Option<usize> {
349    match identifier {
350        TEXT_EMBEDDING_3_LARGE => Some(3_072),
351        TEXT_EMBEDDING_3_SMALL | TEXT_EMBEDDING_ADA_002 => Some(1_536),
352        _ => None,
353    }
354}
355
356#[derive(Debug, Deserialize)]
357pub struct EmbeddingResponse {
358    pub object: String,
359    pub data: Vec<EmbeddingData>,
360    pub model: String,
361    pub usage: Usage,
362}
363
364#[derive(Debug, Deserialize)]
365pub struct EmbeddingData {
366    pub object: String,
367    pub embedding: Vec<f64>,
368    pub index: usize,
369}
370
371#[derive(Clone, Debug, Deserialize)]
372pub struct Usage {
373    pub prompt_tokens: usize,
374    pub total_tokens: usize,
375}
376
377impl GetTokenUsage for Usage {
378    fn token_usage(&self) -> crate::completion::Usage {
379        let mut usage = crate::completion::Usage::new();
380
381        usage.input_tokens = self.prompt_tokens as u64;
382        usage.total_tokens = self.total_tokens as u64;
383        usage.output_tokens = usage.total_tokens - usage.input_tokens;
384
385        usage
386    }
387}
388
389impl std::fmt::Display for Usage {
390    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
391        write!(
392            f,
393            "Prompt tokens: {} Total tokens: {}",
394            self.prompt_tokens, self.total_tokens
395        )
396    }
397}
398
399#[derive(Clone)]
400pub struct EmbeddingModel<T = reqwest::Client> {
401    client: Client<T>,
402    pub model: String,
403    ndims: usize,
404}
405
406impl<T> embeddings::EmbeddingModel for EmbeddingModel<T>
407where
408    T: HttpClientExt + Default + Clone + 'static,
409{
410    const MAX_DOCUMENTS: usize = 1024;
411
412    type Client = Client<T>;
413
414    fn make(client: &Self::Client, model: impl Into<String>, dims: Option<usize>) -> Self {
415        Self::new(client.clone(), model, dims)
416    }
417
418    fn ndims(&self) -> usize {
419        self.ndims
420    }
421
422    async fn embed_texts(
423        &self,
424        documents: impl IntoIterator<Item = String>,
425    ) -> Result<Vec<embeddings::Embedding>, EmbeddingError> {
426        let documents = documents.into_iter().collect::<Vec<_>>();
427
428        let mut body = json!({
429            "input": documents,
430        });
431
432        let body_object = body.as_object_mut().ok_or_else(|| {
433            EmbeddingError::ResponseError("embedding request body must be a JSON object".into())
434        })?;
435
436        if self.ndims > 0 && self.model.as_str() != TEXT_EMBEDDING_ADA_002 {
437            body_object.insert("dimensions".to_owned(), json!(self.ndims));
438        }
439
440        let body = serde_json::to_vec(&body)?;
441
442        let req = self
443            .client
444            .post_embedding(self.model.as_str())?
445            .body(body)
446            .map_err(|e| EmbeddingError::HttpError(e.into()))?;
447
448        let response = self.client.send(req).await?;
449
450        let status = response.status();
451        if status.is_success() {
452            let response_body: Vec<u8> = response.into_body().await?;
453            let parsed: ApiResponse<EmbeddingResponse> = serde_json::from_slice(&response_body)?;
454
455            match parsed {
456                ApiResponse::Ok(response) => {
457                    tracing::info!(target: "rig",
458                        "Azure embedding token usage: {}",
459                        response.usage
460                    );
461
462                    if response.data.len() != documents.len() {
463                        return Err(EmbeddingError::ResponseError(
464                            "Response data length does not match input length".into(),
465                        ));
466                    }
467
468                    Ok(response
469                        .data
470                        .into_iter()
471                        .zip(documents.into_iter())
472                        .map(|(embedding, document)| embeddings::Embedding {
473                            document,
474                            vec: embedding.embedding,
475                        })
476                        .collect())
477                }
478                ApiResponse::Err(err) => {
479                    tracing::warn!(message = %err.message, "provider returned an error response");
480                    Err(EmbeddingError::from_http_response(
481                        status,
482                        String::from_utf8_lossy(&response_body).into_owned(),
483                    ))
484                }
485            }
486        } else {
487            let text = http_client::text(response).await?;
488            Err(EmbeddingError::from_http_response(status, text))
489        }
490    }
491}
492
493impl<T> EmbeddingModel<T> {
494    pub fn new(client: Client<T>, model: impl Into<String>, ndims: Option<usize>) -> Self {
495        let model = model.into();
496        let ndims = ndims
497            .or(model_dimensions_from_identifier(&model))
498            .unwrap_or_default();
499
500        Self {
501            client,
502            model,
503            ndims,
504        }
505    }
506
507    pub fn with_model(client: Client<T>, model: &str, ndims: Option<usize>) -> Self {
508        let ndims = ndims.unwrap_or_default();
509
510        Self {
511            client,
512            model: model.into(),
513            ndims,
514        }
515    }
516}
517
518// ================================================================
519// Azure OpenAI Completion API
520// ================================================================
521
522/// `o1` completion model
523pub const O1: &str = "o1";
524/// `o1-preview` completion model
525pub const O1_PREVIEW: &str = "o1-preview";
526/// `o1-mini` completion model
527pub const O1_MINI: &str = "o1-mini";
528/// `gpt-4o` completion model
529pub const GPT_4O: &str = "gpt-4o";
530/// `gpt-4o-mini` completion model
531pub const GPT_4O_MINI: &str = "gpt-4o-mini";
532/// `gpt-4o-realtime-preview` completion model
533pub const GPT_4O_REALTIME_PREVIEW: &str = "gpt-4o-realtime-preview";
534/// `gpt-4-turbo` completion model
535pub const GPT_4_TURBO: &str = "gpt-4";
536/// `gpt-4` completion model
537pub const GPT_4: &str = "gpt-4";
538/// `gpt-4-32k` completion model
539pub const GPT_4_32K: &str = "gpt-4-32k";
540/// `gpt-4-32k` completion model
541pub const GPT_4_32K_0613: &str = "gpt-4-32k";
542/// `gpt-3.5-turbo` completion model
543pub const GPT_35_TURBO: &str = "gpt-3.5-turbo";
544/// `gpt-3.5-turbo-instruct` completion model
545pub const GPT_35_TURBO_INSTRUCT: &str = "gpt-3.5-turbo-instruct";
546/// `gpt-3.5-turbo-16k` completion model
547pub const GPT_35_TURBO_16K: &str = "gpt-3.5-turbo-16k";
548
549/// Azure OpenAI completion model, driven by the shared OpenAI Chat Completions
550/// path. The deployment-scoped URL (including `api-version`) is produced by
551/// [`completion_path`](crate::providers::openai::completion::OpenAICompatibleProvider::completion_path)
552/// on [`AzureExt`], pinned to the deployment this model handle was created
553/// with (a per-request `model` override changes only the request body, as
554/// before the migration).
555pub type CompletionModel<H = reqwest::Client> =
556    openai::completion::GenericCompletionModel<AzureExt, H>;
557
558impl openai::completion::OpenAICompatibleProvider for AzureExt {
559    const PROVIDER_NAME: &'static str = "azure.openai";
560
561    type StreamingUsage = openai::Usage;
562
563    type Response = openai::CompletionResponse;
564
565    // Azure routes the deployment (model) through the URL path and versions
566    // the API via a query parameter; the client base URL is blank so this
567    // absolute URL passes through `build_uri` untouched.
568    fn completion_path(&self, model: &str) -> String {
569        format!(
570            "{}/openai/deployments/{}/chat/completions?api-version={}",
571            self.endpoint,
572            model.trim_start_matches('/'),
573            self.api_version
574        )
575    }
576}
577
578// ================================================================
579// Azure OpenAI Transcription API
580// ================================================================
581
582#[derive(Clone)]
583pub struct TranscriptionModel<T = reqwest::Client> {
584    client: Client<T>,
585    /// Name of the model (e.g.: gpt-3.5-turbo-1106)
586    pub model: String,
587}
588
589impl<T> TranscriptionModel<T> {
590    pub fn new(client: Client<T>, model: impl Into<String>) -> Self {
591        Self {
592            client,
593            model: model.into(),
594        }
595    }
596}
597
598impl<T> transcription::TranscriptionModel for TranscriptionModel<T>
599where
600    T: HttpClientExt + Clone + 'static,
601{
602    type Response = TranscriptionResponse;
603    type Client = Client<T>;
604
605    fn make(client: &Self::Client, model: impl Into<String>) -> Self {
606        Self::new(client.clone(), model)
607    }
608
609    async fn transcription(
610        &self,
611        request: transcription::TranscriptionRequest,
612    ) -> Result<
613        transcription::TranscriptionResponse<Self::Response>,
614        transcription::TranscriptionError,
615    > {
616        let data = request.data;
617
618        let mut body =
619            MultipartForm::new().part(Part::bytes("file", data).filename(request.filename.clone()));
620
621        if let Some(prompt) = request.prompt {
622            body = body.text("prompt", prompt.clone());
623        }
624
625        if let Some(ref temperature) = request.temperature {
626            body = body.text("temperature", temperature.to_string());
627        }
628
629        if let Some(ref additional_params) = request.additional_params {
630            let params = additional_params.as_object().ok_or_else(|| {
631                TranscriptionError::RequestError(Box::new(std::io::Error::new(
632                    std::io::ErrorKind::InvalidInput,
633                    "additional transcription parameters must be a JSON object",
634                )))
635            })?;
636
637            for (key, value) in params {
638                body = body.text(key.to_owned(), value.to_string());
639            }
640        }
641
642        let req = self
643            .client
644            .post_transcription(&self.model)?
645            .body(body)
646            .map_err(|e| TranscriptionError::HttpError(e.into()))?;
647
648        let response = self.client.send_multipart::<Bytes>(req).await?;
649        let status = response.status();
650        let response_body = response.into_body().into_future().await?.to_vec();
651
652        if status.is_success() {
653            match serde_json::from_slice::<ApiResponse<TranscriptionResponse>>(&response_body)? {
654                ApiResponse::Ok(response) => response.try_into(),
655                ApiResponse::Err(api_error_response) => {
656                    tracing::warn!(message = %api_error_response.message, "provider returned an error response");
657                    Err(TranscriptionError::from_http_response(
658                        status,
659                        String::from_utf8_lossy(&response_body).into_owned(),
660                    ))
661                }
662            }
663        } else {
664            Err(TranscriptionError::from_http_response(
665                status,
666                String::from_utf8_lossy(&response_body).to_string(),
667            ))
668        }
669    }
670}
671
672// ================================================================
673// Azure OpenAI Image Generation API
674// ================================================================
675#[cfg(feature = "image")]
676pub use image_generation::*;
677#[cfg(feature = "image")]
678#[cfg_attr(docsrs, doc(cfg(feature = "image")))]
679mod image_generation {
680    use crate::http_client::HttpClientExt;
681    use crate::image_generation;
682    use crate::image_generation::{ImageGenerationError, ImageGenerationRequest};
683    use crate::providers::azure::{ApiResponse, Client};
684    use crate::providers::openai::ImageGenerationResponse;
685    use bytes::Bytes;
686    use serde_json::json;
687
688    #[derive(Clone)]
689    pub struct ImageGenerationModel<T = reqwest::Client> {
690        client: Client<T>,
691        pub model: String,
692    }
693
694    impl<T> image_generation::ImageGenerationModel for ImageGenerationModel<T>
695    where
696        T: HttpClientExt + Clone + Default + std::fmt::Debug + Send + 'static,
697    {
698        type Response = ImageGenerationResponse;
699
700        type Client = Client<T>;
701
702        fn make(client: &Self::Client, model: impl Into<String>) -> Self {
703            Self {
704                client: client.clone(),
705                model: model.into(),
706            }
707        }
708
709        async fn image_generation(
710            &self,
711            generation_request: ImageGenerationRequest,
712        ) -> Result<image_generation::ImageGenerationResponse<Self::Response>, ImageGenerationError>
713        {
714            let request = json!({
715                "model": self.model,
716                "prompt": generation_request.prompt,
717                "size": format!("{}x{}", generation_request.width, generation_request.height),
718                "response_format": "b64_json"
719            });
720
721            let body = serde_json::to_vec(&request)?;
722
723            let req = self
724                .client
725                .post_image_generation(&self.model)?
726                .body(body)
727                .map_err(|e| ImageGenerationError::HttpError(e.into()))?;
728
729            let response = self.client.send::<_, Bytes>(req).await?;
730            let status = response.status();
731            let response_body = response.into_body().into_future().await?.to_vec();
732
733            if !status.is_success() {
734                return Err(ImageGenerationError::from_http_response(
735                    status,
736                    String::from_utf8_lossy(&response_body).into_owned(),
737                ));
738            }
739
740            match serde_json::from_slice::<ApiResponse<ImageGenerationResponse>>(&response_body)? {
741                ApiResponse::Ok(response) => response.try_into(),
742                ApiResponse::Err(err) => {
743                    tracing::warn!(message = %err.message, "provider returned an error response");
744                    Err(ImageGenerationError::from_http_response(
745                        status,
746                        String::from_utf8_lossy(&response_body).into_owned(),
747                    ))
748                }
749            }
750        }
751    }
752}
753// ================================================================
754// Azure OpenAI Audio Generation API
755// ================================================================
756
757#[cfg(feature = "audio")]
758pub use audio_generation::*;
759
760#[cfg(feature = "audio")]
761#[cfg_attr(docsrs, doc(cfg(feature = "audio")))]
762mod audio_generation {
763    use super::Client;
764    use crate::audio_generation::{
765        self, AudioGenerationError, AudioGenerationRequest, AudioGenerationResponse,
766    };
767    use crate::http_client::HttpClientExt;
768    use bytes::Bytes;
769    use serde_json::json;
770
771    #[derive(Clone)]
772    pub struct AudioGenerationModel<T = reqwest::Client> {
773        client: Client<T>,
774        model: String,
775    }
776
777    impl<T> AudioGenerationModel<T> {
778        pub fn new(client: Client<T>, deployment_name: impl Into<String>) -> Self {
779            Self {
780                client,
781                model: deployment_name.into(),
782            }
783        }
784    }
785
786    impl<T> audio_generation::AudioGenerationModel for AudioGenerationModel<T>
787    where
788        T: HttpClientExt + Clone + Default + std::fmt::Debug + Send + 'static,
789    {
790        type Response = Bytes;
791        type Client = Client<T>;
792
793        fn make(client: &Self::Client, model: impl Into<String>) -> Self {
794            Self::new(client.clone(), model)
795        }
796
797        async fn audio_generation(
798            &self,
799            request: AudioGenerationRequest,
800        ) -> Result<AudioGenerationResponse<Self::Response>, AudioGenerationError> {
801            let request = json!({
802                "model": self.model,
803                "input": request.text,
804                "voice": request.voice,
805                "speed": request.speed,
806            });
807
808            let body = serde_json::to_vec(&request)?;
809
810            let req = self
811                .client
812                .post_audio_generation("/audio/speech")?
813                .header("Content-Type", "application/json")
814                .body(body)
815                .map_err(|e| AudioGenerationError::HttpError(e.into()))?;
816
817            let response = self.client.send::<_, Bytes>(req).await?;
818            let status = response.status();
819            let response_body = response.into_body().into_future().await?;
820
821            if !status.is_success() {
822                return Err(AudioGenerationError::from_http_response(
823                    status,
824                    String::from_utf8_lossy(&response_body).into_owned(),
825                ));
826            }
827
828            Ok(AudioGenerationResponse {
829                audio: response_body.to_vec(),
830                response: response_body,
831            })
832        }
833    }
834}
835
836#[cfg(test)]
837mod azure_tests {
838    use schemars::JsonSchema;
839
840    use super::*;
841    use crate::completion::{CompletionError, CompletionRequest};
842
843    use crate::OneOrMany;
844    use crate::client::{completion::CompletionClient, embeddings::EmbeddingsClient};
845    use crate::completion::CompletionModel;
846    use crate::embeddings::EmbeddingModel;
847    use crate::prelude::TypedPrompt;
848    use crate::providers::openai::GPT_5_MINI;
849
850    #[cfg(any(feature = "image", feature = "audio"))]
851    fn test_client(
852        http_client: crate::test_utils::RecordingHttpClient,
853    ) -> Client<crate::test_utils::RecordingHttpClient> {
854        Client::builder()
855            .api_key("test-key")
856            .azure_endpoint("https://example.openai.azure.com".to_string())
857            .http_client(http_client)
858            .build()
859            .expect("build client")
860    }
861
862    #[cfg(feature = "image")]
863    #[tokio::test]
864    async fn image_generation_non_success_response_preserves_status_and_body() {
865        use crate::image_generation::{
866            ImageGenerationError, ImageGenerationModel as ImageGenerationModelTrait,
867            ImageGenerationRequest,
868        };
869        use crate::test_utils::RecordingHttpClient;
870
871        let body = r#"{"error":{"message":"invalid image request"}}"#;
872        let http_client =
873            RecordingHttpClient::with_error_response(http::StatusCode::BAD_REQUEST, body);
874        let model = ImageGenerationModel::make(&test_client(http_client), "dall-e-3");
875
876        let error = model
877            .image_generation(ImageGenerationRequest {
878                prompt: "draw a cat".to_string(),
879                width: 256,
880                height: 256,
881                additional_params: None,
882            })
883            .await
884            .expect_err("image generation should fail with non-success status");
885
886        assert!(matches!(error, ImageGenerationError::HttpError(_)));
887        assert_eq!(
888            error.provider_response_status(),
889            Some(http::StatusCode::BAD_REQUEST)
890        );
891        assert_eq!(error.provider_response_body(), Some(body));
892    }
893
894    #[cfg(feature = "audio")]
895    #[tokio::test]
896    async fn audio_generation_non_success_response_preserves_status_and_body() {
897        use crate::audio_generation::{
898            AudioGenerationError, AudioGenerationModel as _, AudioGenerationRequest,
899        };
900        use crate::test_utils::RecordingHttpClient;
901
902        let body = r#"{"error":{"message":"invalid voice"}}"#;
903        let http_client =
904            RecordingHttpClient::with_error_response(http::StatusCode::UNPROCESSABLE_ENTITY, body);
905        let model = AudioGenerationModel::new(test_client(http_client), "tts-1");
906
907        let error = match model
908            .audio_generation(AudioGenerationRequest {
909                text: "hello".to_string(),
910                voice: "alloy".to_string(),
911                speed: 1.0,
912                additional_params: None,
913            })
914            .await
915        {
916            Err(error) => error,
917            Ok(_) => panic!("audio generation should fail with non-success status"),
918        };
919
920        assert!(matches!(error, AudioGenerationError::HttpError(_)));
921        assert_eq!(
922            error.provider_response_status(),
923            Some(http::StatusCode::UNPROCESSABLE_ENTITY)
924        );
925        assert_eq!(error.provider_response_body(), Some(body));
926    }
927
928    #[tokio::test]
929    async fn transcription_http_non_success_preserves_status_and_body() {
930        use crate::test_utils::RecordingHttpClient;
931        use crate::transcription::{TranscriptionError, TranscriptionModel as _};
932
933        let body = r#"{"error":{"message":"bad audio","type":"invalid_request_error"}}"#;
934        let http_client =
935            RecordingHttpClient::with_error_response(http::StatusCode::BAD_REQUEST, body);
936        let client = Client::builder()
937            .api_key("test-key")
938            .azure_endpoint("https://example.openai.azure.com".to_string())
939            .http_client(http_client)
940            .build()
941            .expect("build client");
942        let model = TranscriptionModel::new(client, "whisper");
943
944        let error = match model
945            .transcription_request()
946            .data(vec![0u8; 16])
947            .send()
948            .await
949        {
950            Err(error) => error,
951            Ok(_) => panic!("transcription should fail with non-success status"),
952        };
953
954        assert!(matches!(error, TranscriptionError::HttpError(_)));
955        assert_eq!(
956            error.provider_response_status(),
957            Some(http::StatusCode::BAD_REQUEST)
958        );
959        assert_eq!(error.provider_response_body(), Some(body));
960    }
961
962    #[tokio::test]
963    async fn embedding_http_non_success_preserves_status_and_body() {
964        use crate::embeddings::EmbeddingModel as _;
965        use crate::test_utils::RecordingHttpClient;
966
967        let body = r#"{"error":{"message":"bad embedding","type":"invalid_request_error"}}"#;
968        let http_client =
969            RecordingHttpClient::with_error_response(http::StatusCode::BAD_REQUEST, body);
970        let client = Client::builder()
971            .api_key("test-key")
972            .azure_endpoint("https://example.openai.azure.com".to_string())
973            .http_client(http_client)
974            .build()
975            .expect("build client");
976        let model = super::EmbeddingModel::new(client, TEXT_EMBEDDING_3_SMALL, None);
977
978        let error = match model.embed_texts(vec!["Hello, world!".to_string()]).await {
979            Err(error) => error,
980            Ok(_) => panic!("embedding should fail with non-success status"),
981        };
982
983        assert!(matches!(error, EmbeddingError::HttpError(_)));
984        assert_eq!(
985            error.provider_response_status(),
986            Some(http::StatusCode::BAD_REQUEST)
987        );
988        assert_eq!(error.provider_response_body(), Some(body));
989    }
990
991    #[tokio::test]
992    async fn completion_pins_deployment_url_under_model_override() {
993        use crate::completion::CompletionModel as _;
994        use crate::test_utils::RecordingHttpClient;
995
996        // The error response keeps the test independent of response parsing;
997        // only the captured request matters here.
998        let http_client = RecordingHttpClient::with_error_response(
999            http::StatusCode::BAD_REQUEST,
1000            r#"{"error":{"message":"x"}}"#,
1001        );
1002        let client = Client::builder()
1003            .api_key("test-key")
1004            .azure_endpoint("https://example.openai.azure.com".to_string())
1005            .http_client(http_client.clone())
1006            .build()
1007            .expect("build client");
1008        let model = super::CompletionModel::new(client, GPT_4O_MINI);
1009
1010        let _ = model
1011            .completion(CompletionRequest {
1012                model: Some("other-deployment".to_string()),
1013                preamble: None,
1014                chat_history: OneOrMany::one("Hello!".into()),
1015                documents: vec![],
1016                max_tokens: None,
1017                temperature: None,
1018                tools: vec![],
1019                tool_choice: None,
1020                additional_params: None,
1021                output_schema: None,
1022            })
1023            .await;
1024
1025        let requests = http_client.requests();
1026        let request = requests.first().expect("request should be captured");
1027        // The deployment URL stays pinned to the configured model; the
1028        // override only changes the body.
1029        assert!(
1030            request
1031                .uri
1032                .contains("/openai/deployments/gpt-4o-mini/chat/completions"),
1033            "unexpected uri: {}",
1034            request.uri
1035        );
1036        let body: serde_json::Value =
1037            serde_json::from_slice(&request.body).expect("captured body should be JSON");
1038        assert_eq!(body["model"], "other-deployment");
1039    }
1040
1041    #[tokio::test]
1042    async fn completion_http_non_success_preserves_status_and_body() {
1043        use crate::completion::CompletionModel as _;
1044        use crate::test_utils::RecordingHttpClient;
1045
1046        let body = r#"{"error":{"message":"bad completion","type":"invalid_request_error"}}"#;
1047        let http_client =
1048            RecordingHttpClient::with_error_response(http::StatusCode::BAD_REQUEST, body);
1049        let client = Client::builder()
1050            .api_key("test-key")
1051            .azure_endpoint("https://example.openai.azure.com".to_string())
1052            .http_client(http_client)
1053            .build()
1054            .expect("build client");
1055        let model = super::CompletionModel::new(client, GPT_4O_MINI);
1056
1057        let error = match model
1058            .completion(CompletionRequest {
1059                model: None,
1060                preamble: Some("You are a helpful assistant.".to_string()),
1061                chat_history: OneOrMany::one("Hello!".into()),
1062                documents: vec![],
1063                max_tokens: Some(100),
1064                temperature: Some(0.0),
1065                tools: vec![],
1066                tool_choice: None,
1067                additional_params: None,
1068                output_schema: None,
1069            })
1070            .await
1071        {
1072            Err(error) => error,
1073            Ok(_) => panic!("completion should fail with non-success status"),
1074        };
1075
1076        assert!(matches!(error, CompletionError::HttpError(_)));
1077        assert_eq!(
1078            error.provider_response_status(),
1079            Some(http::StatusCode::BAD_REQUEST)
1080        );
1081        assert_eq!(error.provider_response_body(), Some(body));
1082    }
1083
1084    #[tokio::test]
1085    #[ignore]
1086    async fn test_azure_embedding() -> anyhow::Result<()> {
1087        let _ = tracing_subscriber::fmt::try_init();
1088
1089        let client = Client::from_env()?;
1090        let model = client.embedding_model(TEXT_EMBEDDING_3_SMALL);
1091        let embeddings = model.embed_texts(vec!["Hello, world!".to_string()]).await?;
1092
1093        tracing::info!("Azure embedding: {:?}", embeddings);
1094        Ok(())
1095    }
1096
1097    #[tokio::test]
1098    #[ignore]
1099    async fn test_azure_embedding_dimensions() -> anyhow::Result<()> {
1100        let _ = tracing_subscriber::fmt::try_init();
1101
1102        let ndims = 256;
1103        let client = Client::from_env()?;
1104        let model = client.embedding_model_with_ndims(TEXT_EMBEDDING_3_SMALL, ndims);
1105        let embedding = model.embed_text("Hello, world!").await?;
1106
1107        anyhow::ensure!(
1108            embedding.vec.len() == ndims,
1109            "expected embedding dimensions {ndims}, got {}",
1110            embedding.vec.len()
1111        );
1112
1113        tracing::info!("Azure dimensions embedding: {:?}", embedding);
1114        Ok(())
1115    }
1116
1117    #[tokio::test]
1118    #[ignore]
1119    async fn test_azure_completion() -> anyhow::Result<()> {
1120        let _ = tracing_subscriber::fmt::try_init();
1121
1122        let client = Client::from_env()?;
1123        let model = client.completion_model(GPT_4O_MINI);
1124        let completion = model
1125            .completion(CompletionRequest {
1126                model: None,
1127                preamble: Some("You are a helpful assistant.".to_string()),
1128                chat_history: OneOrMany::one("Hello!".into()),
1129                documents: vec![],
1130                max_tokens: Some(100),
1131                temperature: Some(0.0),
1132                tools: vec![],
1133                tool_choice: None,
1134                additional_params: None,
1135                output_schema: None,
1136            })
1137            .await?;
1138
1139        tracing::info!("Azure completion: {:?}", completion);
1140        Ok(())
1141    }
1142
1143    #[tokio::test]
1144    #[ignore]
1145    async fn test_azure_structured_output() -> anyhow::Result<()> {
1146        let _ = tracing_subscriber::fmt::try_init();
1147
1148        #[derive(Debug, Deserialize, JsonSchema)]
1149        struct Person {
1150            name: String,
1151            age: u32,
1152        }
1153
1154        let client = Client::from_env()?;
1155        let agent = client
1156            .agent(GPT_5_MINI)
1157            .preamble("You are a helpful assistant that extracts personal details.")
1158            .max_tokens(100)
1159            .output_schema::<Person>()
1160            .build();
1161
1162        let result: Person = agent
1163            .prompt_typed("Hello! My name is John Doe and I'm 54 years old.")
1164            .await?;
1165
1166        anyhow::ensure!(
1167            result.name == "John Doe",
1168            "expected name John Doe, got {}",
1169            result.name
1170        );
1171        anyhow::ensure!(result.age == 54, "expected age 54, got {}", result.age);
1172
1173        tracing::info!("Extracted person: {:?}", result);
1174        Ok(())
1175    }
1176
1177    #[tokio::test]
1178    async fn test_client_initialization() {
1179        let _client = crate::providers::azure::Client::builder()
1180            .api_key("test")
1181            .azure_endpoint("test".to_string()) // add your endpoint here!
1182            .build()
1183            .expect("Client::builder() failed");
1184    }
1185}