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 crate::client::{self, ApiKey, DebugExt, Provider, ProviderBuilder, ProviderClient};
28use crate::http_client::{self, HttpClientExt, bearer_auth_header};
29use crate::providers::internal::transcription::OpenAiTranscriptionClient;
30use crate::providers::openai;
31// ================================================================
32// Main Azure OpenAI Client
33// ================================================================
34
35const DEFAULT_API_VERSION: &str = "2024-10-21";
36const DEFAULT_AUDIO_API_VERSION: &str = "2025-04-01-preview";
37
38#[derive(Debug, Clone)]
39pub struct AzureExt {
40    endpoint: String,
41    api_version: String,
42    audio_api_version: String,
43}
44
45impl DebugExt for AzureExt {
46    fn fields(&self) -> impl Iterator<Item = (&'static str, &dyn std::fmt::Debug)> {
47        [
48            ("endpoint", (&self.endpoint as &dyn Debug)),
49            ("api_version", (&self.api_version as &dyn Debug)),
50            ("audio_api_version", (&self.audio_api_version as &dyn Debug)),
51        ]
52        .into_iter()
53    }
54}
55
56// TODO: @FayCarsons - this should be a type-safe builder,
57// but that would require extending the `ProviderBuilder`
58// to have some notion of complete vs incomplete states in a
59// given extension builder
60#[derive(Debug, Clone)]
61pub struct AzureExtBuilder {
62    endpoint: Option<String>,
63    api_version: String,
64    audio_api_version: String,
65}
66
67impl Default for AzureExtBuilder {
68    fn default() -> Self {
69        Self {
70            endpoint: None,
71            api_version: DEFAULT_API_VERSION.into(),
72            audio_api_version: DEFAULT_AUDIO_API_VERSION.into(),
73        }
74    }
75}
76
77pub type Client<H = reqwest::Client> = client::Client<AzureExt, H>;
78pub type ClientBuilder<H = crate::markers::Missing> =
79    client::ClientBuilder<AzureExtBuilder, AzureOpenAIAuth, H>;
80
81impl Provider for AzureExt {
82    type Builder = AzureExtBuilder;
83
84    /// Verifying Azure auth without consuming tokens is not supported
85    const VERIFY_PATH: &'static str = "";
86}
87
88client::impl_capabilities!(
89    AzureExt,
90    completion = CompletionModel<H>,
91    embeddings = EmbeddingModel<H>,
92    transcription = TranscriptionModel<H>,
93    image_generation = ImageGenerationModel<H>,
94    audio_generation = AudioGenerationModel<H>,
95);
96
97impl ProviderBuilder for AzureExtBuilder {
98    type Extension<H>
99        = AzureExt
100    where
101        H: HttpClientExt;
102    type ApiKey = AzureOpenAIAuth;
103
104    const BASE_URL: &'static str = "";
105
106    fn build<H>(
107        builder: &client::ClientBuilder<Self, Self::ApiKey, H>,
108    ) -> http_client::Result<Self::Extension<H>>
109    where
110        H: HttpClientExt,
111    {
112        let AzureExtBuilder {
113            endpoint,
114            api_version,
115            audio_api_version,
116            ..
117        } = builder.ext().clone();
118
119        match endpoint {
120            Some(endpoint) => Ok(AzureExt {
121                endpoint,
122                api_version,
123                audio_api_version,
124            }),
125            None => Err(http_client::Error::Instance(
126                "Azure client must be provided an endpoint prior to building".into(),
127            )),
128        }
129    }
130
131    fn finish<H>(
132        &self,
133        mut builder: client::ClientBuilder<Self, Self::ApiKey, H>,
134    ) -> http_client::Result<client::ClientBuilder<Self, Self::ApiKey, H>> {
135        use AzureOpenAIAuth::*;
136
137        let auth = builder.get_api_key().clone();
138
139        match auth {
140            Token(token) => bearer_auth_header(builder.headers_mut(), token.as_str())?,
141            ApiKey(key) => {
142                let k = http::HeaderName::from_static("api-key");
143                let v = http::HeaderValue::from_str(key.as_str())?;
144
145                builder.headers_mut().insert(k, v);
146            }
147        }
148
149        Ok(builder)
150    }
151}
152
153impl<H> ClientBuilder<H> {
154    /// API version to use (e.g., "2024-10-21" for GA, "2024-10-01-preview" for preview)
155    pub fn api_version(mut self, api_version: &str) -> Self {
156        self.ext_mut().api_version = api_version.into();
157
158        self
159    }
160
161    /// API version for audio generation requests.
162    ///
163    /// This defaults to `2025-04-01-preview`, the first deployment-scoped
164    /// Azure API release that exposes text-to-speech.
165    pub fn audio_api_version(mut self, api_version: &str) -> Self {
166        self.ext_mut().audio_api_version = api_version.into();
167
168        self
169    }
170}
171
172impl<H> client::ClientBuilder<AzureExtBuilder, AzureOpenAIAuth, H> {
173    /// Azure OpenAI endpoint URL, for example: https://{your-resource-name}.openai.azure.com
174    pub fn azure_endpoint(self, endpoint: String) -> ClientBuilder<H> {
175        self.over_ext(
176            |AzureExtBuilder {
177                 api_version,
178                 audio_api_version,
179                 ..
180             }| AzureExtBuilder {
181                endpoint: Some(endpoint),
182                api_version,
183                audio_api_version,
184            },
185        )
186    }
187}
188
189/// The authentication type for Azure OpenAI. Can either be an API key or a token.
190/// String types will automatically be coerced to a bearer auth token by default.
191#[derive(Clone)]
192pub enum AzureOpenAIAuth {
193    ApiKey(String),
194    Token(String),
195}
196
197impl ApiKey for AzureOpenAIAuth {}
198
199impl std::fmt::Debug for AzureOpenAIAuth {
200    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
201        match self {
202            Self::ApiKey(_) => write!(f, "API key <REDACTED>"),
203            Self::Token(_) => write!(f, "Token <REDACTED>"),
204        }
205    }
206}
207
208impl<S> From<S> for AzureOpenAIAuth
209where
210    S: Into<String>,
211{
212    fn from(token: S) -> Self {
213        AzureOpenAIAuth::Token(token.into())
214    }
215}
216
217impl<T> Client<T>
218where
219    T: HttpClientExt,
220{
221    fn endpoint(&self) -> &str {
222        &self.ext().endpoint
223    }
224
225    fn api_version(&self) -> &str {
226        &self.ext().api_version
227    }
228
229    #[cfg(feature = "audio")]
230    fn post_audio_generation(
231        &self,
232        deployment_id: &str,
233    ) -> http_client::Result<http_client::Builder> {
234        let url = format!(
235            "{}/openai/deployments/{}/audio/speech?api-version={}",
236            self.endpoint(),
237            deployment_id.trim_start_matches('/'),
238            self.ext().audio_api_version
239        );
240
241        self.post(url)
242    }
243
244    fn post_transcription(&self, deployment_id: &str) -> http_client::Result<http_client::Builder> {
245        let url = format!(
246            "{}/openai/deployments/{}/audio/translations?api-version={}",
247            self.endpoint(),
248            deployment_id.trim_start_matches('/'),
249            self.api_version()
250        );
251
252        self.post(&url)
253    }
254
255    #[cfg(feature = "image")]
256    fn post_image_generation(
257        &self,
258        deployment_id: &str,
259    ) -> http_client::Result<http_client::Builder> {
260        let url = format!(
261            "{}/openai/deployments/{}/images/generations?api-version={}",
262            self.endpoint(),
263            deployment_id.trim_start_matches('/'),
264            self.api_version()
265        );
266
267        self.post(&url)
268    }
269}
270
271pub struct AzureOpenAIClientParams {
272    api_key: String,
273    version: String,
274    header: String,
275}
276
277impl ProviderClient for Client {
278    type Input = AzureOpenAIClientParams;
279    type Error = crate::client::ProviderClientError;
280
281    /// Create a new Azure OpenAI client from the `AZURE_API_KEY` or `AZURE_TOKEN`, `AZURE_API_VERSION`, and `AZURE_ENDPOINT` environment variables.
282    fn from_env() -> Result<Self, Self::Error> {
283        let auth = if let Some(api_key) = crate::client::optional_env_var("AZURE_API_KEY")? {
284            AzureOpenAIAuth::ApiKey(api_key)
285        } else if let Some(token) = crate::client::optional_env_var("AZURE_TOKEN")? {
286            AzureOpenAIAuth::Token(token)
287        } else {
288            return Err(crate::client::ProviderClientError::InvalidConfiguration(
289                "either `AZURE_API_KEY` or `AZURE_TOKEN` must be set",
290            ));
291        };
292
293        let api_version = crate::client::required_env_var("AZURE_API_VERSION")?;
294        let azure_endpoint = crate::client::required_env_var("AZURE_ENDPOINT")?;
295
296        Self::builder()
297            .api_key(auth)
298            .azure_endpoint(azure_endpoint)
299            .api_version(&api_version)
300            .build()
301            .map_err(Into::into)
302    }
303
304    fn from_val(
305        AzureOpenAIClientParams {
306            api_key,
307            version,
308            header,
309        }: Self::Input,
310    ) -> Result<Self, Self::Error> {
311        let auth = AzureOpenAIAuth::ApiKey(api_key.to_string());
312
313        Self::builder()
314            .api_key(auth)
315            .azure_endpoint(header)
316            .api_version(&version)
317            .build()
318            .map_err(Into::into)
319    }
320}
321
322// ================================================================
323// Azure OpenAI Embedding API
324// ================================================================
325
326/// `text-embedding-3-large` embedding model
327pub const TEXT_EMBEDDING_3_LARGE: &str = "text-embedding-3-large";
328/// `text-embedding-3-small` embedding model
329pub const TEXT_EMBEDDING_3_SMALL: &str = "text-embedding-3-small";
330/// `text-embedding-ada-002` embedding model
331pub const TEXT_EMBEDDING_ADA_002: &str = "text-embedding-ada-002";
332
333/// Azure OpenAI embedding model, driven by the shared OpenAI-compatible
334/// embeddings path. `EmbeddingModel::make` (and the client's
335/// `embedding_model` helpers) default unknown dimensions from the model
336/// identifier, exactly like OpenAI.
337pub type EmbeddingModel<T = reqwest::Client> =
338    openai::embedding::GenericEmbeddingModel<AzureExt, T>;
339
340impl openai::embedding::OpenAIEmbeddingsCompatible for AzureExt {
341    const PROVIDER_NAME: &'static str = "azure.openai";
342
343    // Azure addresses the deployment through the URL, so the request body
344    // carries no `model` field.
345    const SENDS_MODEL_FIELD: bool = false;
346
347    fn embeddings_path_for_model(&self, model: &str) -> String {
348        format!(
349            "{}/openai/deployments/{}/embeddings?api-version={}",
350            self.endpoint,
351            model.trim_start_matches('/'),
352            self.api_version
353        )
354    }
355}
356
357// ================================================================
358// Azure OpenAI Completion API
359// ================================================================
360
361/// `o1` completion model
362pub const O1: &str = "o1";
363/// `o1-preview` completion model
364pub const O1_PREVIEW: &str = "o1-preview";
365/// `o1-mini` completion model
366pub const O1_MINI: &str = "o1-mini";
367/// `gpt-4o` completion model
368pub const GPT_4O: &str = "gpt-4o";
369/// `gpt-4o-mini` completion model
370pub const GPT_4O_MINI: &str = "gpt-4o-mini";
371/// `gpt-4o-realtime-preview` completion model
372pub const GPT_4O_REALTIME_PREVIEW: &str = "gpt-4o-realtime-preview";
373/// `gpt-4-turbo` completion model
374pub const GPT_4_TURBO: &str = "gpt-4";
375/// `gpt-4` completion model
376pub const GPT_4: &str = "gpt-4";
377/// `gpt-4-32k` completion model
378pub const GPT_4_32K: &str = "gpt-4-32k";
379/// `gpt-4-32k` completion model
380pub const GPT_4_32K_0613: &str = "gpt-4-32k";
381/// `gpt-3.5-turbo` completion model
382pub const GPT_35_TURBO: &str = "gpt-3.5-turbo";
383/// `gpt-3.5-turbo-instruct` completion model
384pub const GPT_35_TURBO_INSTRUCT: &str = "gpt-3.5-turbo-instruct";
385/// `gpt-3.5-turbo-16k` completion model
386pub const GPT_35_TURBO_16K: &str = "gpt-3.5-turbo-16k";
387
388/// Azure OpenAI completion model, driven by the shared OpenAI Chat Completions
389/// path. The deployment-scoped URL (including `api-version`) is produced by
390/// [`completion_path`](crate::providers::openai::completion::OpenAICompatibleProvider::completion_path)
391/// on [`AzureExt`], pinned to the deployment this model handle was created
392/// with (a per-request `model` override changes only the request body, as
393/// before the migration).
394pub type CompletionModel<H = reqwest::Client> =
395    openai::completion::GenericCompletionModel<AzureExt, H>;
396
397impl openai::completion::OpenAICompatibleProvider for AzureExt {
398    const PROVIDER_NAME: &'static str = "azure.openai";
399
400    type StreamingUsage = openai::Usage;
401
402    type Response = openai::CompletionResponse;
403
404    // Azure routes the deployment (model) through the URL path and versions
405    // the API via a query parameter; the client base URL is blank so this
406    // absolute URL passes through `build_uri` untouched.
407    fn completion_path(&self, model: &str) -> String {
408        format!(
409            "{}/openai/deployments/{}/chat/completions?api-version={}",
410            self.endpoint,
411            model.trim_start_matches('/'),
412            self.api_version
413        )
414    }
415}
416
417// ================================================================
418// Azure OpenAI Transcription API
419// ================================================================
420
421/// Azure OpenAI transcription model; `model` identifies the Azure deployment.
422pub type TranscriptionModel<T = reqwest::Client> =
423    crate::providers::internal::transcription::OpenAiTranscriptionModel<Client<T>>;
424
425impl<T> OpenAiTranscriptionClient for Client<T>
426where
427    T: HttpClientExt + Clone + 'static,
428{
429    const MODEL_IN_FORM: bool = false;
430
431    fn transcription_request(
432        &self,
433        model: &str,
434    ) -> crate::http_client::Result<crate::http_client::Builder> {
435        self.post_transcription(model)
436    }
437}
438
439// ================================================================
440// Azure OpenAI Image Generation API
441// ================================================================
442#[cfg(feature = "image")]
443pub use image_generation::*;
444#[cfg(feature = "image")]
445#[cfg_attr(docsrs, doc(cfg(feature = "image")))]
446mod image_generation {
447    use crate::http_client::HttpClientExt;
448    use crate::image_generation::{ImageGenerationError, ImageGenerationRequest};
449    use crate::providers::azure::AzureExt;
450    use crate::providers::internal::image_generation::{
451        GenericImageGenerationModel, JsonImageGenerationProvider,
452    };
453    use crate::providers::openai::ImageGenerationResponse;
454    use serde_json::json;
455
456    /// Azure OpenAI image generation model; `model` identifies the deployment.
457    pub type ImageGenerationModel<T = reqwest::Client> = GenericImageGenerationModel<AzureExt, T>;
458
459    impl JsonImageGenerationProvider for AzureExt {
460        const IMAGE_GENERATION_PATH: &'static str = "";
461        type Response = ImageGenerationResponse;
462
463        fn image_generation_request_builder<H>(
464            client: &crate::client::Client<Self, H>,
465            model: &str,
466        ) -> Result<crate::http_client::Builder, ImageGenerationError>
467        where
468            H: HttpClientExt,
469        {
470            Ok(client.post_image_generation(model)?)
471        }
472
473        fn image_generation_request_body(
474            _model: &str,
475            generation_request: ImageGenerationRequest,
476        ) -> Result<serde_json::Value, ImageGenerationError> {
477            let request = json!({
478                "prompt": generation_request.prompt,
479                "size": format!("{}x{}", generation_request.width, generation_request.height),
480                "response_format": "b64_json"
481            });
482
483            Ok(request)
484        }
485    }
486}
487// ================================================================
488// Azure OpenAI Audio Generation API
489// ================================================================
490
491#[cfg(feature = "audio")]
492pub use audio_generation::*;
493
494#[cfg(feature = "audio")]
495#[cfg_attr(docsrs, doc(cfg(feature = "audio")))]
496mod audio_generation {
497    use super::AzureExt;
498    use crate::audio_generation::AudioGenerationError;
499    use crate::http_client::HttpClientExt;
500    use crate::providers::internal::audio_generation::{
501        GenericAudioGenerationModel, RawAudioGenerationProvider,
502    };
503
504    /// Azure OpenAI audio generation model; `model` identifies the deployment.
505    pub type AudioGenerationModel<T = reqwest::Client> = GenericAudioGenerationModel<AzureExt, T>;
506
507    impl RawAudioGenerationProvider for AzureExt {
508        const AUDIO_GENERATION_PATH: &'static str = "";
509
510        fn audio_generation_request_builder<H>(
511            client: &crate::client::Client<Self, H>,
512            model: &str,
513        ) -> Result<crate::http_client::Builder, AudioGenerationError>
514        where
515            H: HttpClientExt,
516        {
517            Ok(client.post_audio_generation(model)?)
518        }
519
520        fn audio_generation_request_body(
521            _model: &str,
522            request: crate::audio_generation::AudioGenerationRequest,
523        ) -> Result<serde_json::Value, AudioGenerationError> {
524            Ok(serde_json::json!({
525                "input": request.text,
526                "voice": request.voice,
527                "speed": request.speed,
528            }))
529        }
530    }
531}
532
533#[cfg(test)]
534mod azure_tests {
535    use super::*;
536    use crate::client::{completion::CompletionClient, embeddings::EmbeddingsClient};
537    use crate::completion::CompletionModel;
538    use crate::completion::{CompletionError, CompletionRequest};
539    use crate::embeddings::EmbeddingError;
540    use crate::embeddings::EmbeddingModel;
541
542    #[cfg(any(feature = "image", feature = "audio"))]
543    fn test_client(
544        http_client: crate::test_utils::RecordingHttpClient,
545    ) -> Client<crate::test_utils::RecordingHttpClient> {
546        Client::builder()
547            .api_key("test-key")
548            .azure_endpoint("https://example.openai.azure.com".to_string())
549            .http_client(http_client)
550            .build()
551            .expect("build client")
552    }
553
554    #[cfg(feature = "image")]
555    #[tokio::test]
556    async fn image_generation_client_routes_to_the_deployment() {
557        use crate::client::image_generation::ImageGenerationClient;
558        use crate::image_generation::{ImageGenerationModel as _, ImageGenerationRequest};
559        use crate::test_utils::RecordingHttpClient;
560
561        let http_client =
562            RecordingHttpClient::new(r#"{"created":0,"data":[{"b64_json":"aW1hZ2U="}]}"#);
563        let client = test_client(http_client.clone());
564        let model = client.image_generation_model("image-deployment");
565
566        let response = model
567            .image_generation(ImageGenerationRequest {
568                prompt: "draw a cat".to_owned(),
569                width: 256,
570                height: 256,
571                additional_params: None,
572            })
573            .await
574            .expect("image generation should succeed");
575
576        assert_eq!(response.image, b"image");
577        let requests = http_client.requests();
578        assert_eq!(
579            requests[0].uri,
580            "https://example.openai.azure.com/openai/deployments/image-deployment/images/generations?api-version=2024-10-21"
581        );
582        let body: serde_json::Value =
583            serde_json::from_slice(&requests[0].body).expect("request body should be JSON");
584        assert!(body.get("model").is_none());
585        assert_eq!(body["response_format"], "b64_json");
586    }
587
588    #[cfg(feature = "image")]
589    #[tokio::test]
590    async fn image_generation_non_success_response_preserves_status_and_body() {
591        use crate::image_generation::{
592            ImageGenerationError, ImageGenerationModel as ImageGenerationModelTrait,
593            ImageGenerationRequest,
594        };
595        use crate::test_utils::RecordingHttpClient;
596
597        let body = r#"{"error":{"message":"invalid image request"}}"#;
598        let http_client =
599            RecordingHttpClient::with_error_response(http::StatusCode::BAD_REQUEST, body);
600        let model = ImageGenerationModel::make(&test_client(http_client), "dall-e-3");
601
602        let error = model
603            .image_generation(ImageGenerationRequest {
604                prompt: "draw a cat".to_string(),
605                width: 256,
606                height: 256,
607                additional_params: None,
608            })
609            .await
610            .expect_err("image generation should fail with non-success status");
611
612        assert!(matches!(error, ImageGenerationError::HttpError(_)));
613        assert_eq!(
614            error.provider_response_status(),
615            Some(http::StatusCode::BAD_REQUEST)
616        );
617        assert_eq!(error.provider_response_body(), Some(body));
618    }
619
620    #[cfg(feature = "audio")]
621    #[test]
622    fn audio_api_version_can_be_overridden() {
623        let client = Client::builder()
624            .api_key("test-key")
625            .azure_endpoint("https://example.openai.azure.com".to_owned())
626            .audio_api_version("2026-01-01-preview")
627            .build()
628            .expect("build client");
629        let request = client
630            .post_audio_generation("tts-deployment")
631            .expect("build audio request")
632            .body(Vec::<u8>::new())
633            .expect("finish audio request");
634
635        assert_eq!(
636            request.uri(),
637            "https://example.openai.azure.com/openai/deployments/tts-deployment/audio/speech?api-version=2026-01-01-preview"
638        );
639    }
640
641    #[cfg(feature = "audio")]
642    #[tokio::test]
643    async fn audio_generation_routes_to_the_deployment() {
644        use crate::audio_generation::{AudioGenerationModel as _, AudioGenerationRequest};
645        use crate::client::audio_generation::AudioGenerationClient;
646        use crate::test_utils::RecordingHttpClient;
647
648        let http_client = RecordingHttpClient::new("audio");
649        let client = test_client(http_client.clone());
650        let model = client.audio_generation_model("tts-deployment");
651
652        let response = model
653            .audio_generation(AudioGenerationRequest {
654                text: "hello".to_owned(),
655                voice: "alloy".to_owned(),
656                speed: 1.0,
657                additional_params: None,
658            })
659            .await
660            .expect("audio generation should succeed");
661
662        assert_eq!(response.audio, b"audio");
663        let requests = http_client.requests();
664        assert_eq!(
665            requests[0].uri,
666            "https://example.openai.azure.com/openai/deployments/tts-deployment/audio/speech?api-version=2025-04-01-preview"
667        );
668        let body: serde_json::Value =
669            serde_json::from_slice(&requests[0].body).expect("request body should be JSON");
670        assert!(body.get("model").is_none());
671        assert_eq!(body["input"], "hello");
672        assert_eq!(body["voice"], "alloy");
673    }
674
675    #[cfg(feature = "audio")]
676    #[tokio::test]
677    async fn audio_generation_non_success_response_preserves_status_and_body() {
678        use crate::audio_generation::{
679            AudioGenerationError, AudioGenerationModel as _, AudioGenerationRequest,
680        };
681        use crate::test_utils::RecordingHttpClient;
682
683        let body = r#"{"error":{"message":"invalid voice"}}"#;
684        let http_client =
685            RecordingHttpClient::with_error_response(http::StatusCode::UNPROCESSABLE_ENTITY, body);
686        let model = AudioGenerationModel::new(test_client(http_client), "tts-1");
687
688        let error = match model
689            .audio_generation(AudioGenerationRequest {
690                text: "hello".to_string(),
691                voice: "alloy".to_string(),
692                speed: 1.0,
693                additional_params: None,
694            })
695            .await
696        {
697            Err(error) => error,
698            Ok(_) => panic!("audio generation should fail with non-success status"),
699        };
700
701        assert!(matches!(error, AudioGenerationError::HttpError(_)));
702        assert_eq!(
703            error.provider_response_status(),
704            Some(http::StatusCode::UNPROCESSABLE_ENTITY)
705        );
706        assert_eq!(error.provider_response_body(), Some(body));
707    }
708
709    #[tokio::test]
710    async fn transcription_http_non_success_preserves_status_and_body() {
711        use crate::test_utils::RecordingHttpClient;
712        use crate::transcription::{TranscriptionError, TranscriptionModel as _};
713
714        let body = r#"{"error":{"message":"bad audio","type":"invalid_request_error"}}"#;
715        let http_client =
716            RecordingHttpClient::with_error_response(http::StatusCode::BAD_REQUEST, body);
717        let client = Client::builder()
718            .api_key("test-key")
719            .azure_endpoint("https://example.openai.azure.com".to_string())
720            .http_client(http_client)
721            .build()
722            .expect("build client");
723        let model = TranscriptionModel::new(client, "whisper");
724
725        let error = match model
726            .transcription_request()
727            .data(vec![0u8; 16])
728            .send()
729            .await
730        {
731            Err(error) => error,
732            Ok(_) => panic!("transcription should fail with non-success status"),
733        };
734
735        assert!(matches!(error, TranscriptionError::HttpError(_)));
736        assert_eq!(
737            error.provider_response_status(),
738            Some(http::StatusCode::BAD_REQUEST)
739        );
740        assert_eq!(error.provider_response_body(), Some(body));
741    }
742
743    #[tokio::test]
744    async fn transcription_routes_deployment_in_url_not_multipart_body() {
745        use crate::test_utils::RecordingHttpClient;
746        use crate::transcription::TranscriptionModel as _;
747
748        let http_client = RecordingHttpClient::new(r#"{"text":"transcribed"}"#);
749        let client = Client::builder()
750            .api_key("test-key")
751            .azure_endpoint("https://example.openai.azure.com".to_owned())
752            .http_client(http_client.clone())
753            .build()
754            .expect("build client");
755        let model = TranscriptionModel::new(client, "whisper-deployment");
756
757        let response = model
758            .transcription_request()
759            .data(vec![1, 2, 3])
760            .filename(Some("audio.mp3".to_owned()))
761            .send()
762            .await
763            .expect("transcription should succeed");
764
765        assert_eq!(response.text, "transcribed");
766        let request = http_client
767            .requests()
768            .into_iter()
769            .next()
770            .expect("request should be captured");
771        assert_eq!(
772            request.uri,
773            "https://example.openai.azure.com/openai/deployments/whisper-deployment/audio/translations?api-version=2024-10-21"
774        );
775        let body = String::from_utf8_lossy(&request.body);
776        assert!(!body.contains("name=\"model\""), "{body}");
777        assert!(
778            body.contains("name=\"file\"; filename=\"audio.mp3\""),
779            "{body}"
780        );
781    }
782
783    #[tokio::test]
784    async fn embedding_http_non_success_preserves_status_and_body() {
785        use crate::embeddings::EmbeddingModel as _;
786        use crate::test_utils::RecordingHttpClient;
787
788        let body = r#"{"error":{"message":"bad embedding","type":"invalid_request_error"}}"#;
789        let http_client =
790            RecordingHttpClient::with_error_response(http::StatusCode::BAD_REQUEST, body);
791        let client = Client::builder()
792            .api_key("test-key")
793            .azure_endpoint("https://example.openai.azure.com".to_string())
794            .http_client(http_client)
795            .build()
796            .expect("build client");
797        let model = super::EmbeddingModel::make(&client, TEXT_EMBEDDING_3_SMALL, None);
798
799        let error = match model.embed_texts(vec!["Hello, world!".to_string()]).await {
800            Err(error) => error,
801            Ok(_) => panic!("embedding should fail with non-success status"),
802        };
803
804        assert!(matches!(error, EmbeddingError::HttpError(_)));
805        assert_eq!(
806            error.provider_response_status(),
807            Some(http::StatusCode::BAD_REQUEST)
808        );
809        assert_eq!(error.provider_response_body(), Some(body));
810    }
811
812    #[tokio::test]
813    async fn embedding_preserves_deployment_url_and_body_and_reports_usage() {
814        use crate::embeddings::EmbeddingModel as _;
815        use crate::test_utils::RecordingHttpClient;
816
817        let body = r#"{
818            "object": "list",
819            "model": "text-embedding-3-small",
820            "usage": { "prompt_tokens": 4, "total_tokens": 4 },
821            "data": [{ "object": "embedding", "index": 0, "embedding": [0.1, 0.2] }]
822        }"#;
823        let http_client = RecordingHttpClient::new(body);
824        let client = Client::builder()
825            .api_key("test-key")
826            .azure_endpoint("https://example.openai.azure.com".to_string())
827            .http_client(http_client.clone())
828            .build()
829            .expect("build client");
830        let model = super::EmbeddingModel::make(&client, TEXT_EMBEDDING_3_SMALL, None);
831
832        let response = model
833            .embed_texts_with_usage(vec!["Hello, world!".to_string()])
834            .await
835            .expect("embedding should succeed");
836
837        // Usage is now surfaced instead of the zero-usage default.
838        assert_eq!(response.usage.input_tokens, 4);
839        assert_eq!(response.usage.total_tokens, 4);
840        assert_eq!(response.embeddings.len(), 1);
841
842        // The deployment stays in the URL and the body carries no `model`
843        // field, matching the hand-rolled request this replaced.
844        let requests = http_client.requests();
845        assert_eq!(
846            requests[0].uri,
847            format!(
848                "https://example.openai.azure.com/openai/deployments/{TEXT_EMBEDDING_3_SMALL}/embeddings?api-version=2024-10-21"
849            )
850        );
851        let request_body: serde_json::Value =
852            serde_json::from_slice(&requests[0].body).expect("request body should be JSON");
853        assert_eq!(request_body.get("model"), None);
854        assert_eq!(request_body["dimensions"], serde_json::json!(1_536));
855        assert_eq!(request_body["input"], serde_json::json!(["Hello, world!"]));
856    }
857
858    #[tokio::test]
859    async fn completion_pins_deployment_url_under_model_override() {
860        use crate::completion::CompletionModel as _;
861        use crate::test_utils::RecordingHttpClient;
862
863        // The error response keeps the test independent of response parsing;
864        // only the captured request matters here.
865        let http_client = RecordingHttpClient::with_error_response(
866            http::StatusCode::BAD_REQUEST,
867            r#"{"error":{"message":"x"}}"#,
868        );
869        let client = Client::builder()
870            .api_key("test-key")
871            .azure_endpoint("https://example.openai.azure.com".to_string())
872            .http_client(http_client.clone())
873            .build()
874            .expect("build client");
875        let model = super::CompletionModel::new(client, GPT_4O_MINI);
876
877        let _ = model
878            .completion(CompletionRequest {
879                model: Some("other-deployment".to_string()),
880                preamble: None,
881                chat_history: vec!["Hello!".into()],
882                documents: vec![],
883                max_tokens: None,
884                temperature: None,
885                tools: vec![],
886                tool_choice: None,
887                additional_params: None,
888                output_schema: None,
889                record_telemetry_content: false,
890            })
891            .await;
892
893        let requests = http_client.requests();
894        let request = requests.first().expect("request should be captured");
895        // The deployment URL stays pinned to the configured model; the
896        // override only changes the body.
897        assert!(
898            request
899                .uri
900                .contains("/openai/deployments/gpt-4o-mini/chat/completions"),
901            "unexpected uri: {}",
902            request.uri
903        );
904        let body: serde_json::Value =
905            serde_json::from_slice(&request.body).expect("captured body should be JSON");
906        assert_eq!(body["model"], "other-deployment");
907    }
908
909    #[tokio::test]
910    async fn completion_http_non_success_preserves_status_and_body() {
911        use crate::completion::CompletionModel as _;
912        use crate::test_utils::RecordingHttpClient;
913
914        let body = r#"{"error":{"message":"bad completion","type":"invalid_request_error"}}"#;
915        let http_client =
916            RecordingHttpClient::with_error_response(http::StatusCode::BAD_REQUEST, body);
917        let client = Client::builder()
918            .api_key("test-key")
919            .azure_endpoint("https://example.openai.azure.com".to_string())
920            .http_client(http_client)
921            .build()
922            .expect("build client");
923        let model = super::CompletionModel::new(client, GPT_4O_MINI);
924
925        let error = match model
926            .completion(CompletionRequest {
927                model: None,
928                preamble: Some("You are a helpful assistant.".to_string()),
929                chat_history: vec!["Hello!".into()],
930                documents: vec![],
931                max_tokens: Some(100),
932                temperature: Some(0.0),
933                tools: vec![],
934                tool_choice: None,
935                additional_params: None,
936                output_schema: None,
937                record_telemetry_content: false,
938            })
939            .await
940        {
941            Err(error) => error,
942            Ok(_) => panic!("completion should fail with non-success status"),
943        };
944
945        assert!(matches!(error, CompletionError::HttpError(_)));
946        assert_eq!(
947            error.provider_response_status(),
948            Some(http::StatusCode::BAD_REQUEST)
949        );
950        assert_eq!(error.provider_response_body(), Some(body));
951    }
952
953    #[tokio::test]
954    #[ignore]
955    async fn test_azure_embedding() -> anyhow::Result<()> {
956        let _ = tracing_subscriber::fmt::try_init();
957
958        let client = Client::from_env()?;
959        let model = client.embedding_model(TEXT_EMBEDDING_3_SMALL);
960        let embeddings = model.embed_texts(vec!["Hello, world!".to_string()]).await?;
961
962        tracing::info!("Azure embedding: {:?}", embeddings);
963        Ok(())
964    }
965
966    #[tokio::test]
967    #[ignore]
968    async fn test_azure_embedding_dimensions() -> anyhow::Result<()> {
969        let _ = tracing_subscriber::fmt::try_init();
970
971        let ndims = 256;
972        let client = Client::from_env()?;
973        let model = client.embedding_model_with_ndims(TEXT_EMBEDDING_3_SMALL, ndims);
974        let embedding = model.embed_text("Hello, world!").await?;
975
976        anyhow::ensure!(
977            embedding.vec.len() == ndims,
978            "expected embedding dimensions {ndims}, got {}",
979            embedding.vec.len()
980        );
981
982        tracing::info!("Azure dimensions embedding: {:?}", embedding);
983        Ok(())
984    }
985
986    #[tokio::test]
987    #[ignore]
988    async fn test_azure_completion() -> anyhow::Result<()> {
989        let _ = tracing_subscriber::fmt::try_init();
990
991        let client = Client::from_env()?;
992        let model = client.completion_model(GPT_4O_MINI);
993        let completion = model
994            .completion(CompletionRequest {
995                model: None,
996                preamble: Some("You are a helpful assistant.".to_string()),
997                chat_history: vec!["Hello!".into()],
998                documents: vec![],
999                max_tokens: Some(100),
1000                temperature: Some(0.0),
1001                tools: vec![],
1002                tool_choice: None,
1003                additional_params: None,
1004                output_schema: None,
1005                record_telemetry_content: false,
1006            })
1007            .await?;
1008
1009        tracing::info!("Azure completion: {:?}", completion);
1010        Ok(())
1011    }
1012
1013    #[tokio::test]
1014    async fn test_client_initialization() {
1015        let _client = crate::providers::azure::Client::builder()
1016            .api_key("test")
1017            .azure_endpoint("test".to_string()) // add your endpoint here!
1018            .build()
1019            .expect("Client::builder() failed");
1020    }
1021}