1use 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;
44const 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#[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 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 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 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#[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 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
337pub const TEXT_EMBEDDING_3_LARGE: &str = "text-embedding-3-large";
343pub const TEXT_EMBEDDING_3_SMALL: &str = "text-embedding-3-small";
345pub 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
518pub const O1: &str = "o1";
524pub const O1_PREVIEW: &str = "o1-preview";
526pub const O1_MINI: &str = "o1-mini";
528pub const GPT_4O: &str = "gpt-4o";
530pub const GPT_4O_MINI: &str = "gpt-4o-mini";
532pub const GPT_4O_REALTIME_PREVIEW: &str = "gpt-4o-realtime-preview";
534pub const GPT_4_TURBO: &str = "gpt-4";
536pub const GPT_4: &str = "gpt-4";
538pub const GPT_4_32K: &str = "gpt-4-32k";
540pub const GPT_4_32K_0613: &str = "gpt-4-32k";
542pub const GPT_35_TURBO: &str = "gpt-3.5-turbo";
544pub const GPT_35_TURBO_INSTRUCT: &str = "gpt-3.5-turbo-instruct";
546pub const GPT_35_TURBO_16K: &str = "gpt-3.5-turbo-16k";
548
549pub 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 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#[derive(Clone)]
583pub struct TranscriptionModel<T = reqwest::Client> {
584 client: Client<T>,
585 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#[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#[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 super::*;
839 use crate::completion::{CompletionError, CompletionRequest};
840
841 use crate::OneOrMany;
842 use crate::client::{completion::CompletionClient, embeddings::EmbeddingsClient};
843 use crate::completion::CompletionModel;
844 use crate::embeddings::EmbeddingModel;
845
846 #[cfg(any(feature = "image", feature = "audio"))]
847 fn test_client(
848 http_client: crate::test_utils::RecordingHttpClient,
849 ) -> Client<crate::test_utils::RecordingHttpClient> {
850 Client::builder()
851 .api_key("test-key")
852 .azure_endpoint("https://example.openai.azure.com".to_string())
853 .http_client(http_client)
854 .build()
855 .expect("build client")
856 }
857
858 #[cfg(feature = "image")]
859 #[tokio::test]
860 async fn image_generation_non_success_response_preserves_status_and_body() {
861 use crate::image_generation::{
862 ImageGenerationError, ImageGenerationModel as ImageGenerationModelTrait,
863 ImageGenerationRequest,
864 };
865 use crate::test_utils::RecordingHttpClient;
866
867 let body = r#"{"error":{"message":"invalid image request"}}"#;
868 let http_client =
869 RecordingHttpClient::with_error_response(http::StatusCode::BAD_REQUEST, body);
870 let model = ImageGenerationModel::make(&test_client(http_client), "dall-e-3");
871
872 let error = model
873 .image_generation(ImageGenerationRequest {
874 prompt: "draw a cat".to_string(),
875 width: 256,
876 height: 256,
877 additional_params: None,
878 })
879 .await
880 .expect_err("image generation should fail with non-success status");
881
882 assert!(matches!(error, ImageGenerationError::HttpError(_)));
883 assert_eq!(
884 error.provider_response_status(),
885 Some(http::StatusCode::BAD_REQUEST)
886 );
887 assert_eq!(error.provider_response_body(), Some(body));
888 }
889
890 #[cfg(feature = "audio")]
891 #[tokio::test]
892 async fn audio_generation_non_success_response_preserves_status_and_body() {
893 use crate::audio_generation::{
894 AudioGenerationError, AudioGenerationModel as _, AudioGenerationRequest,
895 };
896 use crate::test_utils::RecordingHttpClient;
897
898 let body = r#"{"error":{"message":"invalid voice"}}"#;
899 let http_client =
900 RecordingHttpClient::with_error_response(http::StatusCode::UNPROCESSABLE_ENTITY, body);
901 let model = AudioGenerationModel::new(test_client(http_client), "tts-1");
902
903 let error = match model
904 .audio_generation(AudioGenerationRequest {
905 text: "hello".to_string(),
906 voice: "alloy".to_string(),
907 speed: 1.0,
908 additional_params: None,
909 })
910 .await
911 {
912 Err(error) => error,
913 Ok(_) => panic!("audio generation should fail with non-success status"),
914 };
915
916 assert!(matches!(error, AudioGenerationError::HttpError(_)));
917 assert_eq!(
918 error.provider_response_status(),
919 Some(http::StatusCode::UNPROCESSABLE_ENTITY)
920 );
921 assert_eq!(error.provider_response_body(), Some(body));
922 }
923
924 #[tokio::test]
925 async fn transcription_http_non_success_preserves_status_and_body() {
926 use crate::test_utils::RecordingHttpClient;
927 use crate::transcription::{TranscriptionError, TranscriptionModel as _};
928
929 let body = r#"{"error":{"message":"bad audio","type":"invalid_request_error"}}"#;
930 let http_client =
931 RecordingHttpClient::with_error_response(http::StatusCode::BAD_REQUEST, body);
932 let client = Client::builder()
933 .api_key("test-key")
934 .azure_endpoint("https://example.openai.azure.com".to_string())
935 .http_client(http_client)
936 .build()
937 .expect("build client");
938 let model = TranscriptionModel::new(client, "whisper");
939
940 let error = match model
941 .transcription_request()
942 .data(vec![0u8; 16])
943 .send()
944 .await
945 {
946 Err(error) => error,
947 Ok(_) => panic!("transcription should fail with non-success status"),
948 };
949
950 assert!(matches!(error, TranscriptionError::HttpError(_)));
951 assert_eq!(
952 error.provider_response_status(),
953 Some(http::StatusCode::BAD_REQUEST)
954 );
955 assert_eq!(error.provider_response_body(), Some(body));
956 }
957
958 #[tokio::test]
959 async fn embedding_http_non_success_preserves_status_and_body() {
960 use crate::embeddings::EmbeddingModel as _;
961 use crate::test_utils::RecordingHttpClient;
962
963 let body = r#"{"error":{"message":"bad embedding","type":"invalid_request_error"}}"#;
964 let http_client =
965 RecordingHttpClient::with_error_response(http::StatusCode::BAD_REQUEST, body);
966 let client = Client::builder()
967 .api_key("test-key")
968 .azure_endpoint("https://example.openai.azure.com".to_string())
969 .http_client(http_client)
970 .build()
971 .expect("build client");
972 let model = super::EmbeddingModel::new(client, TEXT_EMBEDDING_3_SMALL, None);
973
974 let error = match model.embed_texts(vec!["Hello, world!".to_string()]).await {
975 Err(error) => error,
976 Ok(_) => panic!("embedding should fail with non-success status"),
977 };
978
979 assert!(matches!(error, EmbeddingError::HttpError(_)));
980 assert_eq!(
981 error.provider_response_status(),
982 Some(http::StatusCode::BAD_REQUEST)
983 );
984 assert_eq!(error.provider_response_body(), Some(body));
985 }
986
987 #[tokio::test]
988 async fn completion_pins_deployment_url_under_model_override() {
989 use crate::completion::CompletionModel as _;
990 use crate::test_utils::RecordingHttpClient;
991
992 let http_client = RecordingHttpClient::with_error_response(
995 http::StatusCode::BAD_REQUEST,
996 r#"{"error":{"message":"x"}}"#,
997 );
998 let client = Client::builder()
999 .api_key("test-key")
1000 .azure_endpoint("https://example.openai.azure.com".to_string())
1001 .http_client(http_client.clone())
1002 .build()
1003 .expect("build client");
1004 let model = super::CompletionModel::new(client, GPT_4O_MINI);
1005
1006 let _ = model
1007 .completion(CompletionRequest {
1008 model: Some("other-deployment".to_string()),
1009 preamble: None,
1010 chat_history: OneOrMany::one("Hello!".into()),
1011 documents: vec![],
1012 max_tokens: None,
1013 temperature: None,
1014 tools: vec![],
1015 tool_choice: None,
1016 additional_params: None,
1017 output_schema: None,
1018 record_telemetry_content: false,
1019 })
1020 .await;
1021
1022 let requests = http_client.requests();
1023 let request = requests.first().expect("request should be captured");
1024 assert!(
1027 request
1028 .uri
1029 .contains("/openai/deployments/gpt-4o-mini/chat/completions"),
1030 "unexpected uri: {}",
1031 request.uri
1032 );
1033 let body: serde_json::Value =
1034 serde_json::from_slice(&request.body).expect("captured body should be JSON");
1035 assert_eq!(body["model"], "other-deployment");
1036 }
1037
1038 #[tokio::test]
1039 async fn completion_http_non_success_preserves_status_and_body() {
1040 use crate::completion::CompletionModel as _;
1041 use crate::test_utils::RecordingHttpClient;
1042
1043 let body = r#"{"error":{"message":"bad completion","type":"invalid_request_error"}}"#;
1044 let http_client =
1045 RecordingHttpClient::with_error_response(http::StatusCode::BAD_REQUEST, body);
1046 let client = Client::builder()
1047 .api_key("test-key")
1048 .azure_endpoint("https://example.openai.azure.com".to_string())
1049 .http_client(http_client)
1050 .build()
1051 .expect("build client");
1052 let model = super::CompletionModel::new(client, GPT_4O_MINI);
1053
1054 let error = match model
1055 .completion(CompletionRequest {
1056 model: None,
1057 preamble: Some("You are a helpful assistant.".to_string()),
1058 chat_history: OneOrMany::one("Hello!".into()),
1059 documents: vec![],
1060 max_tokens: Some(100),
1061 temperature: Some(0.0),
1062 tools: vec![],
1063 tool_choice: None,
1064 additional_params: None,
1065 output_schema: None,
1066 record_telemetry_content: false,
1067 })
1068 .await
1069 {
1070 Err(error) => error,
1071 Ok(_) => panic!("completion should fail with non-success status"),
1072 };
1073
1074 assert!(matches!(error, CompletionError::HttpError(_)));
1075 assert_eq!(
1076 error.provider_response_status(),
1077 Some(http::StatusCode::BAD_REQUEST)
1078 );
1079 assert_eq!(error.provider_response_body(), Some(body));
1080 }
1081
1082 #[tokio::test]
1083 #[ignore]
1084 async fn test_azure_embedding() -> anyhow::Result<()> {
1085 let _ = tracing_subscriber::fmt::try_init();
1086
1087 let client = Client::from_env()?;
1088 let model = client.embedding_model(TEXT_EMBEDDING_3_SMALL);
1089 let embeddings = model.embed_texts(vec!["Hello, world!".to_string()]).await?;
1090
1091 tracing::info!("Azure embedding: {:?}", embeddings);
1092 Ok(())
1093 }
1094
1095 #[tokio::test]
1096 #[ignore]
1097 async fn test_azure_embedding_dimensions() -> anyhow::Result<()> {
1098 let _ = tracing_subscriber::fmt::try_init();
1099
1100 let ndims = 256;
1101 let client = Client::from_env()?;
1102 let model = client.embedding_model_with_ndims(TEXT_EMBEDDING_3_SMALL, ndims);
1103 let embedding = model.embed_text("Hello, world!").await?;
1104
1105 anyhow::ensure!(
1106 embedding.vec.len() == ndims,
1107 "expected embedding dimensions {ndims}, got {}",
1108 embedding.vec.len()
1109 );
1110
1111 tracing::info!("Azure dimensions embedding: {:?}", embedding);
1112 Ok(())
1113 }
1114
1115 #[tokio::test]
1116 #[ignore]
1117 async fn test_azure_completion() -> anyhow::Result<()> {
1118 let _ = tracing_subscriber::fmt::try_init();
1119
1120 let client = Client::from_env()?;
1121 let model = client.completion_model(GPT_4O_MINI);
1122 let completion = model
1123 .completion(CompletionRequest {
1124 model: None,
1125 preamble: Some("You are a helpful assistant.".to_string()),
1126 chat_history: OneOrMany::one("Hello!".into()),
1127 documents: vec![],
1128 max_tokens: Some(100),
1129 temperature: Some(0.0),
1130 tools: vec![],
1131 tool_choice: None,
1132 additional_params: None,
1133 output_schema: None,
1134 record_telemetry_content: false,
1135 })
1136 .await?;
1137
1138 tracing::info!("Azure completion: {:?}", completion);
1139 Ok(())
1140 }
1141
1142 #[tokio::test]
1143 async fn test_client_initialization() {
1144 let _client = crate::providers::azure::Client::builder()
1145 .api_key("test")
1146 .azure_endpoint("test".to_string()) .build()
1148 .expect("Client::builder() failed");
1149 }
1150}