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