1use std::sync::Arc;
86
87use agent_framework_core::client::{ChatClient, ChatStream};
88use agent_framework_core::error::{Error, Result};
89use agent_framework_core::types::{ChatOptions, ChatResponse, Message};
90use futures::StreamExt;
91use serde_json::{json, Map, Value};
92
93use crate::{Auth, TokenCredential};
94
95const DEFAULT_API_VERSION: &str = "preview";
103
104pub struct AzureOpenAIResponsesClient {
109 inner: Arc<Inner>,
110}
111
112#[derive(Clone)]
113struct Inner {
114 http: reqwest::Client,
115 endpoint: String,
118 base_url: Option<String>,
121 deployment: String,
122 api_version: Option<String>,
123 auth: Auth,
124 implicit_encrypted_reasoning: bool,
128}
129
130impl Clone for AzureOpenAIResponsesClient {
131 fn clone(&self) -> Self {
132 Self {
133 inner: self.inner.clone(),
134 }
135 }
136}
137
138impl std::fmt::Debug for AzureOpenAIResponsesClient {
139 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
140 f.debug_struct("AzureOpenAIResponsesClient")
141 .field("endpoint", &self.inner.endpoint)
142 .field("base_url", &self.inner.base_url)
143 .field("deployment", &self.inner.deployment)
144 .field("api_version", &self.inner.api_version)
145 .field(
146 "auth",
147 &match &self.inner.auth {
148 Auth::ApiKey(_) => "api-key",
149 Auth::Credential(_) => "token-credential",
150 },
151 )
152 .finish_non_exhaustive()
153 }
154}
155
156impl AzureOpenAIResponsesClient {
157 pub fn new(
160 endpoint: impl Into<String>,
161 deployment: impl Into<String>,
162 api_key: impl Into<String>,
163 ) -> Self {
164 Self {
165 inner: Arc::new(Inner {
166 http: reqwest::Client::new(),
167 endpoint: endpoint.into(),
168 base_url: None,
169 deployment: deployment.into(),
170 api_version: Some(DEFAULT_API_VERSION.to_string()),
171 auth: Auth::ApiKey(api_key.into()),
172 implicit_encrypted_reasoning: true,
173 }),
174 }
175 }
176
177 pub fn with_token_credential(
180 endpoint: impl Into<String>,
181 deployment: impl Into<String>,
182 credential: Arc<dyn TokenCredential>,
183 ) -> Self {
184 Self {
185 inner: Arc::new(Inner {
186 http: reqwest::Client::new(),
187 endpoint: endpoint.into(),
188 base_url: None,
189 deployment: deployment.into(),
190 api_version: Some(DEFAULT_API_VERSION.to_string()),
191 auth: Auth::Credential(credential),
192 implicit_encrypted_reasoning: true,
193 }),
194 }
195 }
196
197 pub fn from_env() -> Result<Self> {
209 Self::from_env_vars(|key| std::env::var(key).ok())
210 }
211
212 fn from_env_vars(get: impl Fn(&str) -> Option<String>) -> Result<Self> {
223 let endpoint = get("AZURE_OPENAI_ENDPOINT")
224 .ok_or_else(|| Error::Configuration("AZURE_OPENAI_ENDPOINT is not set".into()))?;
225 let api_key = get("AZURE_OPENAI_API_KEY")
226 .ok_or_else(|| Error::Configuration("AZURE_OPENAI_API_KEY is not set".into()))?;
227 let deployment = get("AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME").ok_or_else(|| {
228 Error::Configuration("AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME is not set".into())
229 })?;
230 let mut client = Self::new(endpoint, deployment, api_key);
231 match get("AZURE_OPENAI_API_VERSION") {
232 Some(v) if v.is_empty() => client = client.without_api_version(),
235 Some(v) => client = client.with_api_version(v),
236 None => {}
237 }
238 if let Some(b) = get("AZURE_OPENAI_BASE_URL") {
239 client = client.with_base_url(b);
240 }
241 Ok(client)
242 }
243
244 pub fn with_api_version(mut self, api_version: impl Into<String>) -> Self {
246 Arc::make_mut(&mut self.inner).api_version = Some(api_version.into());
247 self
248 }
249
250 pub fn without_api_version(mut self) -> Self {
262 Arc::make_mut(&mut self.inner).api_version = None;
263 self
264 }
265
266 pub fn without_implicit_encrypted_reasoning(mut self) -> Self {
279 Arc::make_mut(&mut self.inner).implicit_encrypted_reasoning = false;
280 self
281 }
282
283 pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
289 Arc::make_mut(&mut self.inner).base_url = Some(base_url.into());
290 self
291 }
292
293 pub fn deployment(&self) -> &str {
296 &self.inner.deployment
297 }
298
299 pub fn api_version(&self) -> Option<&str> {
302 self.inner.api_version.as_deref()
303 }
304
305 pub fn base_url(&self) -> Option<&str> {
310 self.inner.base_url.as_deref()
311 }
312
313 fn url(&self) -> String {
314 let base = match &self.inner.base_url {
315 Some(explicit) => explicit.trim_end_matches('/').to_string(),
316 None => format!("{}/openai/v1", self.inner.endpoint.trim_end_matches('/')),
317 };
318 match &self.inner.api_version {
319 Some(v) => format!("{base}/responses?api-version={v}"),
320 None => format!("{base}/responses"),
321 }
322 }
323
324 fn build_body(&self, messages: &[Message], options: &ChatOptions, stream: bool) -> Value {
328 let mut body = Map::new();
329 let model = options
336 .model
337 .clone()
338 .unwrap_or_else(|| self.inner.deployment.clone());
339 body.insert("model".into(), json!(model));
340
341 let (instructions, rest) = agent_framework_openai::responses::extract_instructions(
342 messages,
343 options.instructions.as_deref(),
344 );
345 if let Some(instructions) = instructions {
346 body.insert("instructions".into(), json!(instructions));
347 }
348 body.insert(
349 "input".into(),
350 json!(agent_framework_openai::responses::messages_to_input(rest)),
351 );
352
353 if let Some(conversation_id) = &options.conversation_id {
354 body.insert("previous_response_id".into(), json!(conversation_id));
355 }
356 if let Some(t) = options.temperature {
357 body.insert("temperature".into(), json!(t));
358 }
359 if let Some(t) = options.top_p {
360 body.insert("top_p".into(), json!(t));
361 }
362 if let Some(mt) = options.max_tokens {
363 body.insert("max_output_tokens".into(), json!(mt));
364 }
365 if let Some(store) = options.store {
366 body.insert("store".into(), json!(store));
367 }
368 if let Some(user) = &options.user {
369 body.insert("user".into(), json!(user));
370 }
371 if let Some(metadata) = &options.metadata {
372 body.insert("metadata".into(), json!(metadata));
373 }
374
375 if !options.tools.is_empty() {
376 let tools: Vec<Value> = options
377 .tools
378 .iter()
379 .map(agent_framework_openai::responses::tool_to_responses_spec)
380 .collect();
381 body.insert("tools".into(), json!(tools));
382 if let Some(allow_multi) = options.allow_multiple_tool_calls {
383 body.insert("parallel_tool_calls".into(), json!(allow_multi));
384 }
385 }
386 if let Some(tool_choice) = &options.tool_choice {
387 body.insert(
388 "tool_choice".into(),
389 agent_framework_openai::responses::tool_choice_to_responses(tool_choice),
390 );
391 }
392 if let Some(fmt) = &options.response_format {
393 body.insert(
394 "text".into(),
395 json!({ "format": agent_framework_openai::responses::response_format_to_text(fmt) }),
396 );
397 }
398
399 if let Some(include) = agent_framework_openai::responses::responses_include(
401 options,
402 self.inner.implicit_encrypted_reasoning,
403 ) {
404 body.insert("include".into(), include);
405 }
406
407 for (k, v) in &options.additional_properties {
408 if k == "include" {
411 continue;
412 }
413 body.entry(k.clone()).or_insert_with(|| v.clone());
414 }
415
416 if stream {
417 body.insert("stream".into(), json!(true));
418 }
419 Value::Object(body)
420 }
421
422 async fn auth_header(&self) -> Result<(&'static str, String)> {
426 match &self.inner.auth {
427 Auth::ApiKey(key) => Ok(("api-key", key.clone())),
428 Auth::Credential(credential) => {
429 let token = credential.get_token().await?;
430 Ok(("Authorization", format!("Bearer {token}")))
431 }
432 }
433 }
434
435 async fn post(&self, body: &Value) -> Result<reqwest::Response> {
436 let (header_name, header_value) = self.auth_header().await?;
437 let resp = self
438 .inner
439 .http
440 .post(self.url())
441 .header(header_name, header_value)
442 .json(body)
443 .send()
444 .await
445 .map_err(|e| Error::service(format!("request failed: {e}")))?;
446 if !resp.status().is_success() {
447 let status = resp.status();
448 let retry_after = crate::parse_retry_after(resp.headers());
449 let text = resp.text().await.unwrap_or_default();
450 return Err(agent_framework_openai::classify_service_error(
453 status.as_u16(),
454 &text,
455 format!("Azure OpenAI API error {status}: {text}"),
456 retry_after,
457 ));
458 }
459 Ok(resp)
460 }
461}
462
463#[async_trait::async_trait]
464impl ChatClient for AzureOpenAIResponsesClient {
465 async fn get_response(
466 &self,
467 messages: Vec<Message>,
468 options: ChatOptions,
469 ) -> Result<ChatResponse> {
470 let body = self.build_body(&messages, &options, false);
471 let resp = self.post(&body).await?;
472 let value: Value = resp
473 .json()
474 .await
475 .map_err(|e| Error::service(format!("invalid response json: {e}")))?;
476 if let Some(err) = agent_framework_openai::responses::response_failure_error(&value) {
481 return Err(err);
482 }
483 Ok(agent_framework_openai::responses::parse_response(
484 &value,
485 options.store,
486 ))
487 }
488
489 async fn get_streaming_response(
490 &self,
491 messages: Vec<Message>,
492 options: ChatOptions,
493 ) -> Result<ChatStream> {
494 let body = self.build_body(&messages, &options, true);
495 let resp = self.post(&body).await?;
496 Ok(
497 agent_framework_openai::responses::parse_responses_sse_stream(resp, options.store)
498 .boxed(),
499 )
500 }
501
502 fn model(&self) -> Option<&str> {
503 Some(&self.inner.deployment)
504 }
505}
506
507#[cfg(test)]
508mod tests {
509 use super::*;
510 use agent_framework_core::tools::{ApprovalMode, ToolDefinition, ToolKind};
511 use agent_framework_core::types::{Content, FunctionArguments, FunctionCallContent, ToolMode};
512
513 fn client() -> AzureOpenAIResponsesClient {
514 AzureOpenAIResponsesClient::new(
515 "https://my-resource.openai.azure.com",
516 "my-gpt4o-deployment",
517 "test-key",
518 )
519 }
520
521 fn user(text: &str) -> Message {
522 Message::user(text)
523 }
524
525 #[test]
528 fn url_uses_v1_responses_route_with_default_preview_api_version() {
529 let c = client();
530 assert_eq!(
531 c.url(),
532 "https://my-resource.openai.azure.com/openai/v1/responses?api-version=preview"
533 );
534 }
535
536 #[test]
537 fn url_trims_trailing_slash_on_endpoint() {
538 let c = AzureOpenAIResponsesClient::new(
539 "https://my-resource.openai.azure.com/",
540 "my-gpt4o-deployment",
541 "test-key",
542 );
543 assert_eq!(
544 c.url(),
545 "https://my-resource.openai.azure.com/openai/v1/responses?api-version=preview"
546 );
547 }
548
549 #[test]
550 fn url_has_no_deployment_segment_unlike_chat_completions() {
551 let c = client();
554 assert!(!c.url().contains("deployments"));
555 assert!(!c.url().contains("my-gpt4o-deployment"));
556 }
557
558 #[test]
559 fn with_api_version_overrides_default() {
560 let c = client().with_api_version("2025-04-01-preview");
561 assert!(c.url().ends_with("api-version=2025-04-01-preview"));
562 }
563
564 #[test]
565 fn implicit_encrypted_reasoning_is_on_by_default_and_can_be_turned_off() {
566 let body = client().build_body(&[user("hi")], &ChatOptions::new(), false);
570 assert_eq!(body["include"], json!(["reasoning.encrypted_content"]));
571
572 let body = client().without_implicit_encrypted_reasoning().build_body(
575 &[user("hi")],
576 &ChatOptions::new(),
577 false,
578 );
579 assert!(
580 body.get("include").is_none(),
581 "no include expected, got: {}",
582 body
583 );
584 }
585
586 #[test]
587 fn an_explicit_empty_include_is_omitted_not_sent_as_an_empty_array() {
588 let mut options = ChatOptions::new();
591 options
592 .additional_properties
593 .insert("include".into(), json!([]));
594 let body = client().without_implicit_encrypted_reasoning().build_body(
595 &[user("hi")],
596 &options,
597 false,
598 );
599 assert!(
600 body.get("include").is_none(),
601 "an empty include should be omitted entirely, got: {}",
602 body
603 );
604 }
605
606 #[test]
607 fn turning_off_the_implicit_add_still_honors_an_explicit_request() {
608 let mut options = ChatOptions::new();
611 options
612 .additional_properties
613 .insert("include".into(), json!(["reasoning.encrypted_content"]));
614 let body = client().without_implicit_encrypted_reasoning().build_body(
615 &[user("hi")],
616 &options,
617 false,
618 );
619 assert_eq!(body["include"], json!(["reasoning.encrypted_content"]));
620 }
621
622 #[test]
623 fn without_api_version_omits_the_query_parameter() {
624 let c = client().without_api_version();
627 assert_eq!(
628 c.url(),
629 "https://my-resource.openai.azure.com/openai/v1/responses"
630 );
631 assert_eq!(c.api_version(), None);
632
633 let gateway = client()
634 .with_base_url("https://gateway.example.com/openai/v1/")
635 .without_api_version();
636 assert_eq!(
637 gateway.url(),
638 "https://gateway.example.com/openai/v1/responses"
639 );
640 }
641
642 #[test]
643 fn from_env_empty_api_version_opts_out_of_the_query() {
644 let c = AzureOpenAIResponsesClient::from_env_vars(|k| match k {
645 "AZURE_OPENAI_ENDPOINT" => Some("https://my-resource.openai.azure.com".into()),
646 "AZURE_OPENAI_API_KEY" => Some("key".into()),
647 "AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME" => Some("dep".into()),
648 "AZURE_OPENAI_API_VERSION" => Some(String::new()),
649 _ => None,
650 })
651 .unwrap();
652 assert!(!c.url().contains("api-version"));
653 }
654
655 #[test]
656 fn with_base_url_overrides_derived_route() {
657 let c = client().with_base_url("https://gateway.example.com/openai/v1/");
658 assert_eq!(
659 c.url(),
660 "https://gateway.example.com/openai/v1/responses?api-version=preview"
661 );
662 assert_eq!(c.base_url(), Some("https://gateway.example.com/openai/v1/"));
663 }
664
665 #[test]
666 fn accessors_report_configured_deployment_and_api_version() {
667 let c = client();
668 assert_eq!(c.deployment(), "my-gpt4o-deployment");
669 assert_eq!(c.api_version(), Some("preview"));
670 assert_eq!(c.model(), Some("my-gpt4o-deployment"));
671 assert_eq!(c.base_url(), None);
672 }
673
674 #[tokio::test]
679 async fn api_key_auth_uses_api_key_header() {
680 let c = client();
681 let (name, value) = c.auth_header().await.unwrap();
682 assert_eq!(name, "api-key");
683 assert_eq!(value, "test-key");
684 }
685
686 #[tokio::test]
687 async fn token_credential_auth_uses_bearer_header() {
688 let credential = Arc::new(crate::StaticTokenCredential::new("my-jwt-token"));
689 let c = AzureOpenAIResponsesClient::with_token_credential(
690 "https://my-resource.openai.azure.com",
691 "my-gpt4o-deployment",
692 credential,
693 );
694 let (name, value) = c.auth_header().await.unwrap();
695 assert_eq!(name, "Authorization");
696 assert_eq!(value, "Bearer my-jwt-token");
697 }
698
699 #[test]
712 fn build_body_simple_text_matches_openai_shape_with_deployment_as_model() {
713 let c = client();
714 let body = c.build_body(&[user("Hello there")], &ChatOptions::new(), false);
715 assert_eq!(
716 body,
717 json!({
718 "model": "my-gpt4o-deployment",
719 "input": [
720 { "type": "message", "role": "user", "content": [
721 { "type": "input_text", "text": "Hello there" }
722 ]}
723 ],
724 "include": ["reasoning.encrypted_content"],
726 })
727 );
728 }
729
730 #[test]
731 fn build_body_model_always_present_unlike_chat_completions() {
732 let c = client();
737 let body = c.build_body(&[user("hi")], &ChatOptions::new(), false);
738 assert_eq!(body["model"], json!("my-gpt4o-deployment"));
739 }
740
741 #[test]
742 fn build_body_model_override_wins_over_deployment() {
743 let c = client();
744 let options = ChatOptions::new().with_model("gpt-4o-override");
745 let body = c.build_body(&[user("hi")], &options, false);
746 assert_eq!(body["model"], json!("gpt-4o-override"));
747 }
748
749 #[test]
750 fn build_body_extracts_leading_system_message_as_instructions() {
751 let c = client();
752 let messages = vec![Message::system("Be terse."), user("Hi")];
753 let body = c.build_body(&messages, &ChatOptions::new(), false);
754 assert_eq!(body["instructions"], json!("Be terse."));
755 assert_eq!(
756 body["input"],
757 json!([
758 { "type": "message", "role": "user", "content": [
759 { "type": "input_text", "text": "Hi" }
760 ]}
761 ])
762 );
763 }
764
765 #[test]
766 fn build_body_function_call_round_trip() {
767 let c = client();
768 let call = FunctionCallContent::new(
769 "call_1",
770 "get_weather",
771 Some(FunctionArguments::Raw(r#"{"city":"Paris"}"#.to_string())),
772 );
773 let assistant_msg = Message::with_contents(
774 agent_framework_core::types::Role::assistant(),
775 vec![Content::FunctionCall(call)],
776 );
777 let tool_msg = Message::with_contents(
778 agent_framework_core::types::Role::tool(),
779 vec![Content::FunctionResult(
780 agent_framework_core::types::FunctionResultContent::new(
781 "call_1",
782 Some(json!("18C and sunny")),
783 ),
784 )],
785 );
786 let body = c.build_body(
787 &[user("weather?"), assistant_msg, tool_msg],
788 &ChatOptions::new(),
789 false,
790 );
791 assert_eq!(
792 body["input"],
793 json!([
794 { "type": "message", "role": "user", "content": [
795 { "type": "input_text", "text": "weather?" }
796 ]},
797 { "type": "function_call", "call_id": "call_1", "name": "get_weather", "arguments": "{\"city\":\"Paris\"}" },
798 { "type": "function_call_output", "call_id": "call_1", "output": "18C and sunny" },
799 ])
800 );
801 }
802
803 #[test]
804 fn build_body_tools_are_flat_not_nested() {
805 let c = client();
806 let tool = ToolDefinition {
807 name: "get_weather".into(),
808 description: "Get the weather".into(),
809 parameters: json!({ "type": "object", "properties": {} }),
810 kind: ToolKind::Function,
811 approval_mode: ApprovalMode::NeverRequire,
812 executor: None,
813 };
814 let options = ChatOptions::new().with_tool(tool);
815 let body = c.build_body(&[user("hi")], &options, false);
816 assert_eq!(
817 body["tools"],
818 json!([{
819 "type": "function",
820 "name": "get_weather",
821 "description": "Get the weather",
822 "parameters": { "type": "object", "properties": {} },
823 }])
824 );
825 }
826
827 #[test]
828 fn build_body_tool_choice_required_named() {
829 let c = client();
830 let options =
831 ChatOptions::new().with_tool_choice(ToolMode::Required(Some("get_weather".into())));
832 let body = c.build_body(&[user("hi")], &options, false);
833 assert_eq!(
834 body["tool_choice"],
835 json!({ "type": "function", "name": "get_weather" })
836 );
837 }
838
839 #[test]
840 fn build_body_conversation_id_becomes_previous_response_id() {
841 let c = client();
842 let mut options = ChatOptions::new();
843 options.conversation_id = Some("resp_abc123".into());
844 let body = c.build_body(&[user("hi")], &options, false);
845 assert_eq!(body["previous_response_id"], json!("resp_abc123"));
846 }
847
848 #[test]
849 fn build_body_max_tokens_becomes_max_output_tokens() {
850 let c = client();
851 let options = ChatOptions::new().with_max_tokens(256);
852 let body = c.build_body(&[user("hi")], &options, false);
853 assert_eq!(body["max_output_tokens"], json!(256));
854 assert!(body.get("max_tokens").is_none());
855 }
856
857 #[test]
858 fn build_body_stream_sets_stream_flag_without_stream_options() {
859 let c = client();
863 let body = c.build_body(&[user("hi")], &ChatOptions::new(), true);
864 assert_eq!(body["stream"], json!(true));
865 assert!(body.get("stream_options").is_none());
866 }
867
868 #[test]
873 fn parse_response_reuses_openai_responses_convert() {
874 let value = json!({
875 "id": "resp_abc123",
876 "model": "my-gpt4o-deployment",
877 "status": "completed",
878 "output": [{
879 "type": "message",
880 "role": "assistant",
881 "content": [{ "type": "output_text", "text": "Hello!" }],
882 }],
883 "usage": { "input_tokens": 10, "output_tokens": 5, "total_tokens": 15 },
884 });
885 let resp = agent_framework_openai::responses::parse_response(&value, None);
886 assert_eq!(resp.text(), "Hello!");
887 assert_eq!(resp.response_id.as_deref(), Some("resp_abc123"));
888 assert_eq!(resp.conversation_id.as_deref(), Some("resp_abc123"));
891 assert_eq!(resp.usage_details.unwrap().total_token_count, Some(15));
892 }
893
894 #[test]
908 fn from_env_reads_all_vars() {
909 let vars = [
910 ("AZURE_OPENAI_ENDPOINT", "https://res.openai.azure.com"),
911 ("AZURE_OPENAI_API_KEY", "test-key-123"),
912 (
913 "AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME",
914 "gpt-4o-responses-deployment",
915 ),
916 ("AZURE_OPENAI_API_VERSION", "2025-05-01-preview"),
917 (
918 "AZURE_OPENAI_BASE_URL",
919 "https://gateway.example.com/openai/v1/",
920 ),
921 ]
922 .into_iter()
923 .collect::<std::collections::HashMap<_, _>>();
924
925 let client =
926 AzureOpenAIResponsesClient::from_env_vars(|k| vars.get(k).map(|v| v.to_string()))
927 .unwrap();
928 assert_eq!(client.inner.endpoint, "https://res.openai.azure.com");
929 assert_eq!(client.inner.deployment, "gpt-4o-responses-deployment");
930 assert_eq!(
931 client.inner.api_version.as_deref(),
932 Some("2025-05-01-preview")
933 );
934 assert_eq!(
935 client.inner.base_url.as_deref(),
936 Some("https://gateway.example.com/openai/v1/")
937 );
938 assert!(matches!(client.inner.auth, Auth::ApiKey(ref k) if k == "test-key-123"));
939 }
940
941 #[test]
942 fn from_env_defaults_api_version_to_preview_when_unset() {
943 let vars = [
944 ("AZURE_OPENAI_ENDPOINT", "https://res.openai.azure.com"),
945 ("AZURE_OPENAI_API_KEY", "test-key-123"),
946 (
947 "AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME",
948 "gpt-4o-responses-deployment",
949 ),
950 ]
951 .into_iter()
952 .collect::<std::collections::HashMap<_, _>>();
953
954 let client =
955 AzureOpenAIResponsesClient::from_env_vars(|k| vars.get(k).map(|v| v.to_string()))
956 .unwrap();
957 assert_eq!(
958 client.inner.api_version.as_deref(),
959 Some(DEFAULT_API_VERSION)
960 );
961 assert_eq!(client.inner.base_url, None);
962 }
963
964 #[test]
965 fn from_env_errors_when_responses_deployment_missing() {
966 let vars = [
967 ("AZURE_OPENAI_ENDPOINT", "https://res.openai.azure.com"),
968 ("AZURE_OPENAI_API_KEY", "test-key-123"),
969 ]
970 .into_iter()
971 .collect::<std::collections::HashMap<_, _>>();
972
973 let err = AzureOpenAIResponsesClient::from_env_vars(|k| vars.get(k).map(|v| v.to_string()))
974 .unwrap_err();
975 assert!(err
976 .to_string()
977 .contains("AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"));
978 }
979
980 #[test]
981 fn from_env_errors_when_endpoint_missing() {
982 let vars = [
983 ("AZURE_OPENAI_API_KEY", "test-key-123"),
984 (
985 "AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME",
986 "gpt-4o-responses-deployment",
987 ),
988 ]
989 .into_iter()
990 .collect::<std::collections::HashMap<_, _>>();
991
992 let err = AzureOpenAIResponsesClient::from_env_vars(|k| vars.get(k).map(|v| v.to_string()))
993 .unwrap_err();
994 assert!(err.to_string().contains("AZURE_OPENAI_ENDPOINT"));
995 }
996
997 }