1use 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;
31const 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#[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 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 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 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 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#[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 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
322pub const TEXT_EMBEDDING_3_LARGE: &str = "text-embedding-3-large";
328pub const TEXT_EMBEDDING_3_SMALL: &str = "text-embedding-3-small";
330pub const TEXT_EMBEDDING_ADA_002: &str = "text-embedding-ada-002";
332
333pub 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 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
357pub const O1: &str = "o1";
363pub const O1_PREVIEW: &str = "o1-preview";
365pub const O1_MINI: &str = "o1-mini";
367pub const GPT_4O: &str = "gpt-4o";
369pub const GPT_4O_MINI: &str = "gpt-4o-mini";
371pub const GPT_4O_REALTIME_PREVIEW: &str = "gpt-4o-realtime-preview";
373pub const GPT_4_TURBO: &str = "gpt-4";
375pub const GPT_4: &str = "gpt-4";
377pub const GPT_4_32K: &str = "gpt-4-32k";
379pub const GPT_4_32K_0613: &str = "gpt-4-32k";
381pub const GPT_35_TURBO: &str = "gpt-3.5-turbo";
383pub const GPT_35_TURBO_INSTRUCT: &str = "gpt-3.5-turbo-instruct";
385pub const GPT_35_TURBO_16K: &str = "gpt-3.5-turbo-16k";
387
388pub 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 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
417pub 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#[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 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#[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 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 assert_eq!(response.usage.input_tokens, 4);
839 assert_eq!(response.usage.total_tokens, 4);
840 assert_eq!(response.embeddings.len(), 1);
841
842 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 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 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()) .build()
1019 .expect("Client::builder() failed");
1020 }
1021}