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}
125
126impl Clone for AzureOpenAIResponsesClient {
127 fn clone(&self) -> Self {
128 Self {
129 inner: self.inner.clone(),
130 }
131 }
132}
133
134impl std::fmt::Debug for AzureOpenAIResponsesClient {
135 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
136 f.debug_struct("AzureOpenAIResponsesClient")
137 .field("endpoint", &self.inner.endpoint)
138 .field("base_url", &self.inner.base_url)
139 .field("deployment", &self.inner.deployment)
140 .field("api_version", &self.inner.api_version)
141 .field(
142 "auth",
143 &match &self.inner.auth {
144 Auth::ApiKey(_) => "api-key",
145 Auth::Credential(_) => "token-credential",
146 },
147 )
148 .finish_non_exhaustive()
149 }
150}
151
152impl AzureOpenAIResponsesClient {
153 pub fn new(
156 endpoint: impl Into<String>,
157 deployment: impl Into<String>,
158 api_key: impl Into<String>,
159 ) -> Self {
160 Self {
161 inner: Arc::new(Inner {
162 http: reqwest::Client::new(),
163 endpoint: endpoint.into(),
164 base_url: None,
165 deployment: deployment.into(),
166 api_version: Some(DEFAULT_API_VERSION.to_string()),
167 auth: Auth::ApiKey(api_key.into()),
168 }),
169 }
170 }
171
172 pub fn with_token_credential(
175 endpoint: impl Into<String>,
176 deployment: impl Into<String>,
177 credential: Arc<dyn TokenCredential>,
178 ) -> Self {
179 Self {
180 inner: Arc::new(Inner {
181 http: reqwest::Client::new(),
182 endpoint: endpoint.into(),
183 base_url: None,
184 deployment: deployment.into(),
185 api_version: Some(DEFAULT_API_VERSION.to_string()),
186 auth: Auth::Credential(credential),
187 }),
188 }
189 }
190
191 pub fn from_env() -> Result<Self> {
203 Self::from_env_vars(|key| std::env::var(key).ok())
204 }
205
206 fn from_env_vars(get: impl Fn(&str) -> Option<String>) -> Result<Self> {
217 let endpoint = get("AZURE_OPENAI_ENDPOINT")
218 .ok_or_else(|| Error::Configuration("AZURE_OPENAI_ENDPOINT is not set".into()))?;
219 let api_key = get("AZURE_OPENAI_API_KEY")
220 .ok_or_else(|| Error::Configuration("AZURE_OPENAI_API_KEY is not set".into()))?;
221 let deployment = get("AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME").ok_or_else(|| {
222 Error::Configuration("AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME is not set".into())
223 })?;
224 let mut client = Self::new(endpoint, deployment, api_key);
225 match get("AZURE_OPENAI_API_VERSION") {
226 Some(v) if v.is_empty() => client = client.without_api_version(),
229 Some(v) => client = client.with_api_version(v),
230 None => {}
231 }
232 if let Some(b) = get("AZURE_OPENAI_BASE_URL") {
233 client = client.with_base_url(b);
234 }
235 Ok(client)
236 }
237
238 pub fn with_api_version(mut self, api_version: impl Into<String>) -> Self {
240 Arc::make_mut(&mut self.inner).api_version = Some(api_version.into());
241 self
242 }
243
244 pub fn without_api_version(mut self) -> Self {
256 Arc::make_mut(&mut self.inner).api_version = None;
257 self
258 }
259
260 pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
266 Arc::make_mut(&mut self.inner).base_url = Some(base_url.into());
267 self
268 }
269
270 pub fn deployment(&self) -> &str {
273 &self.inner.deployment
274 }
275
276 pub fn api_version(&self) -> Option<&str> {
279 self.inner.api_version.as_deref()
280 }
281
282 pub fn base_url(&self) -> Option<&str> {
287 self.inner.base_url.as_deref()
288 }
289
290 fn url(&self) -> String {
291 let base = match &self.inner.base_url {
292 Some(explicit) => explicit.trim_end_matches('/').to_string(),
293 None => format!("{}/openai/v1", self.inner.endpoint.trim_end_matches('/')),
294 };
295 match &self.inner.api_version {
296 Some(v) => format!("{base}/responses?api-version={v}"),
297 None => format!("{base}/responses"),
298 }
299 }
300
301 fn build_body(&self, messages: &[Message], options: &ChatOptions, stream: bool) -> Value {
305 let mut body = Map::new();
306 let model = options
313 .model
314 .clone()
315 .unwrap_or_else(|| self.inner.deployment.clone());
316 body.insert("model".into(), json!(model));
317
318 let (instructions, rest) = agent_framework_openai::responses::extract_instructions(
319 messages,
320 options.instructions.as_deref(),
321 );
322 if let Some(instructions) = instructions {
323 body.insert("instructions".into(), json!(instructions));
324 }
325 body.insert(
326 "input".into(),
327 json!(agent_framework_openai::responses::messages_to_input(rest)),
328 );
329
330 if let Some(conversation_id) = &options.conversation_id {
331 body.insert("previous_response_id".into(), json!(conversation_id));
332 }
333 if let Some(t) = options.temperature {
334 body.insert("temperature".into(), json!(t));
335 }
336 if let Some(t) = options.top_p {
337 body.insert("top_p".into(), json!(t));
338 }
339 if let Some(mt) = options.max_tokens {
340 body.insert("max_output_tokens".into(), json!(mt));
341 }
342 if let Some(store) = options.store {
343 body.insert("store".into(), json!(store));
344 }
345 if let Some(user) = &options.user {
346 body.insert("user".into(), json!(user));
347 }
348 if let Some(metadata) = &options.metadata {
349 body.insert("metadata".into(), json!(metadata));
350 }
351
352 if !options.tools.is_empty() {
353 let tools: Vec<Value> = options
354 .tools
355 .iter()
356 .map(agent_framework_openai::responses::tool_to_responses_spec)
357 .collect();
358 body.insert("tools".into(), json!(tools));
359 if let Some(allow_multi) = options.allow_multiple_tool_calls {
360 body.insert("parallel_tool_calls".into(), json!(allow_multi));
361 }
362 }
363 if let Some(tool_choice) = &options.tool_choice {
364 body.insert(
365 "tool_choice".into(),
366 agent_framework_openai::responses::tool_choice_to_responses(tool_choice),
367 );
368 }
369 if let Some(fmt) = &options.response_format {
370 body.insert(
371 "text".into(),
372 json!({ "format": agent_framework_openai::responses::response_format_to_text(fmt) }),
373 );
374 }
375
376 for (k, v) in &options.additional_properties {
377 body.entry(k.clone()).or_insert_with(|| v.clone());
378 }
379
380 if stream {
381 body.insert("stream".into(), json!(true));
382 }
383 Value::Object(body)
384 }
385
386 async fn auth_header(&self) -> Result<(&'static str, String)> {
390 match &self.inner.auth {
391 Auth::ApiKey(key) => Ok(("api-key", key.clone())),
392 Auth::Credential(credential) => {
393 let token = credential.get_token().await?;
394 Ok(("Authorization", format!("Bearer {token}")))
395 }
396 }
397 }
398
399 async fn post(&self, body: &Value) -> Result<reqwest::Response> {
400 let (header_name, header_value) = self.auth_header().await?;
401 let resp = self
402 .inner
403 .http
404 .post(self.url())
405 .header(header_name, header_value)
406 .json(body)
407 .send()
408 .await
409 .map_err(|e| Error::service(format!("request failed: {e}")))?;
410 if !resp.status().is_success() {
411 let status = resp.status();
412 let retry_after = crate::parse_retry_after(resp.headers());
413 let text = resp.text().await.unwrap_or_default();
414 return Err(agent_framework_openai::classify_service_error(
417 status.as_u16(),
418 &text,
419 format!("Azure OpenAI API error {status}: {text}"),
420 retry_after,
421 ));
422 }
423 Ok(resp)
424 }
425}
426
427#[async_trait::async_trait]
428impl ChatClient for AzureOpenAIResponsesClient {
429 async fn get_response(
430 &self,
431 messages: Vec<Message>,
432 options: ChatOptions,
433 ) -> Result<ChatResponse> {
434 let body = self.build_body(&messages, &options, false);
435 let resp = self.post(&body).await?;
436 let value: Value = resp
437 .json()
438 .await
439 .map_err(|e| Error::service(format!("invalid response json: {e}")))?;
440 if let Some(err) = agent_framework_openai::responses::response_failure_error(&value) {
445 return Err(err);
446 }
447 Ok(agent_framework_openai::responses::parse_response(
448 &value,
449 options.store,
450 ))
451 }
452
453 async fn get_streaming_response(
454 &self,
455 messages: Vec<Message>,
456 options: ChatOptions,
457 ) -> Result<ChatStream> {
458 let body = self.build_body(&messages, &options, true);
459 let resp = self.post(&body).await?;
460 Ok(
461 agent_framework_openai::responses::parse_responses_sse_stream(resp, options.store)
462 .boxed(),
463 )
464 }
465
466 fn model(&self) -> Option<&str> {
467 Some(&self.inner.deployment)
468 }
469}
470
471#[cfg(test)]
472mod tests {
473 use super::*;
474 use agent_framework_core::tools::{ApprovalMode, ToolDefinition, ToolKind};
475 use agent_framework_core::types::{Content, FunctionArguments, FunctionCallContent, ToolMode};
476
477 fn client() -> AzureOpenAIResponsesClient {
478 AzureOpenAIResponsesClient::new(
479 "https://my-resource.openai.azure.com",
480 "my-gpt4o-deployment",
481 "test-key",
482 )
483 }
484
485 fn user(text: &str) -> Message {
486 Message::user(text)
487 }
488
489 #[test]
492 fn url_uses_v1_responses_route_with_default_preview_api_version() {
493 let c = client();
494 assert_eq!(
495 c.url(),
496 "https://my-resource.openai.azure.com/openai/v1/responses?api-version=preview"
497 );
498 }
499
500 #[test]
501 fn url_trims_trailing_slash_on_endpoint() {
502 let c = AzureOpenAIResponsesClient::new(
503 "https://my-resource.openai.azure.com/",
504 "my-gpt4o-deployment",
505 "test-key",
506 );
507 assert_eq!(
508 c.url(),
509 "https://my-resource.openai.azure.com/openai/v1/responses?api-version=preview"
510 );
511 }
512
513 #[test]
514 fn url_has_no_deployment_segment_unlike_chat_completions() {
515 let c = client();
518 assert!(!c.url().contains("deployments"));
519 assert!(!c.url().contains("my-gpt4o-deployment"));
520 }
521
522 #[test]
523 fn with_api_version_overrides_default() {
524 let c = client().with_api_version("2025-04-01-preview");
525 assert!(c.url().ends_with("api-version=2025-04-01-preview"));
526 }
527
528 #[test]
529 fn without_api_version_omits_the_query_parameter() {
530 let c = client().without_api_version();
533 assert_eq!(
534 c.url(),
535 "https://my-resource.openai.azure.com/openai/v1/responses"
536 );
537 assert_eq!(c.api_version(), None);
538
539 let gateway = client()
540 .with_base_url("https://gateway.example.com/openai/v1/")
541 .without_api_version();
542 assert_eq!(
543 gateway.url(),
544 "https://gateway.example.com/openai/v1/responses"
545 );
546 }
547
548 #[test]
549 fn from_env_empty_api_version_opts_out_of_the_query() {
550 let c = AzureOpenAIResponsesClient::from_env_vars(|k| match k {
551 "AZURE_OPENAI_ENDPOINT" => Some("https://my-resource.openai.azure.com".into()),
552 "AZURE_OPENAI_API_KEY" => Some("key".into()),
553 "AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME" => Some("dep".into()),
554 "AZURE_OPENAI_API_VERSION" => Some(String::new()),
555 _ => None,
556 })
557 .unwrap();
558 assert!(!c.url().contains("api-version"));
559 }
560
561 #[test]
562 fn with_base_url_overrides_derived_route() {
563 let c = client().with_base_url("https://gateway.example.com/openai/v1/");
564 assert_eq!(
565 c.url(),
566 "https://gateway.example.com/openai/v1/responses?api-version=preview"
567 );
568 assert_eq!(c.base_url(), Some("https://gateway.example.com/openai/v1/"));
569 }
570
571 #[test]
572 fn accessors_report_configured_deployment_and_api_version() {
573 let c = client();
574 assert_eq!(c.deployment(), "my-gpt4o-deployment");
575 assert_eq!(c.api_version(), Some("preview"));
576 assert_eq!(c.model(), Some("my-gpt4o-deployment"));
577 assert_eq!(c.base_url(), None);
578 }
579
580 #[tokio::test]
585 async fn api_key_auth_uses_api_key_header() {
586 let c = client();
587 let (name, value) = c.auth_header().await.unwrap();
588 assert_eq!(name, "api-key");
589 assert_eq!(value, "test-key");
590 }
591
592 #[tokio::test]
593 async fn token_credential_auth_uses_bearer_header() {
594 let credential = Arc::new(crate::StaticTokenCredential::new("my-jwt-token"));
595 let c = AzureOpenAIResponsesClient::with_token_credential(
596 "https://my-resource.openai.azure.com",
597 "my-gpt4o-deployment",
598 credential,
599 );
600 let (name, value) = c.auth_header().await.unwrap();
601 assert_eq!(name, "Authorization");
602 assert_eq!(value, "Bearer my-jwt-token");
603 }
604
605 #[test]
618 fn build_body_simple_text_matches_openai_shape_with_deployment_as_model() {
619 let c = client();
620 let body = c.build_body(&[user("Hello there")], &ChatOptions::new(), false);
621 assert_eq!(
622 body,
623 json!({
624 "model": "my-gpt4o-deployment",
625 "input": [
626 { "type": "message", "role": "user", "content": [
627 { "type": "input_text", "text": "Hello there" }
628 ]}
629 ],
630 })
631 );
632 }
633
634 #[test]
635 fn build_body_model_always_present_unlike_chat_completions() {
636 let c = client();
641 let body = c.build_body(&[user("hi")], &ChatOptions::new(), false);
642 assert_eq!(body["model"], json!("my-gpt4o-deployment"));
643 }
644
645 #[test]
646 fn build_body_model_override_wins_over_deployment() {
647 let c = client();
648 let options = ChatOptions::new().with_model("gpt-4o-override");
649 let body = c.build_body(&[user("hi")], &options, false);
650 assert_eq!(body["model"], json!("gpt-4o-override"));
651 }
652
653 #[test]
654 fn build_body_extracts_leading_system_message_as_instructions() {
655 let c = client();
656 let messages = vec![Message::system("Be terse."), user("Hi")];
657 let body = c.build_body(&messages, &ChatOptions::new(), false);
658 assert_eq!(body["instructions"], json!("Be terse."));
659 assert_eq!(
660 body["input"],
661 json!([
662 { "type": "message", "role": "user", "content": [
663 { "type": "input_text", "text": "Hi" }
664 ]}
665 ])
666 );
667 }
668
669 #[test]
670 fn build_body_function_call_round_trip() {
671 let c = client();
672 let call = FunctionCallContent::new(
673 "call_1",
674 "get_weather",
675 Some(FunctionArguments::Raw(r#"{"city":"Paris"}"#.to_string())),
676 );
677 let assistant_msg = Message::with_contents(
678 agent_framework_core::types::Role::assistant(),
679 vec![Content::FunctionCall(call)],
680 );
681 let tool_msg = Message::with_contents(
682 agent_framework_core::types::Role::tool(),
683 vec![Content::FunctionResult(
684 agent_framework_core::types::FunctionResultContent::new(
685 "call_1",
686 Some(json!("18C and sunny")),
687 ),
688 )],
689 );
690 let body = c.build_body(
691 &[user("weather?"), assistant_msg, tool_msg],
692 &ChatOptions::new(),
693 false,
694 );
695 assert_eq!(
696 body["input"],
697 json!([
698 { "type": "message", "role": "user", "content": [
699 { "type": "input_text", "text": "weather?" }
700 ]},
701 { "type": "function_call", "call_id": "call_1", "name": "get_weather", "arguments": "{\"city\":\"Paris\"}" },
702 { "type": "function_call_output", "call_id": "call_1", "output": "18C and sunny" },
703 ])
704 );
705 }
706
707 #[test]
708 fn build_body_tools_are_flat_not_nested() {
709 let c = client();
710 let tool = ToolDefinition {
711 name: "get_weather".into(),
712 description: "Get the weather".into(),
713 parameters: json!({ "type": "object", "properties": {} }),
714 kind: ToolKind::Function,
715 approval_mode: ApprovalMode::NeverRequire,
716 executor: None,
717 };
718 let options = ChatOptions::new().with_tool(tool);
719 let body = c.build_body(&[user("hi")], &options, false);
720 assert_eq!(
721 body["tools"],
722 json!([{
723 "type": "function",
724 "name": "get_weather",
725 "description": "Get the weather",
726 "parameters": { "type": "object", "properties": {} },
727 }])
728 );
729 }
730
731 #[test]
732 fn build_body_tool_choice_required_named() {
733 let c = client();
734 let options =
735 ChatOptions::new().with_tool_choice(ToolMode::Required(Some("get_weather".into())));
736 let body = c.build_body(&[user("hi")], &options, false);
737 assert_eq!(
738 body["tool_choice"],
739 json!({ "type": "function", "name": "get_weather" })
740 );
741 }
742
743 #[test]
744 fn build_body_conversation_id_becomes_previous_response_id() {
745 let c = client();
746 let mut options = ChatOptions::new();
747 options.conversation_id = Some("resp_abc123".into());
748 let body = c.build_body(&[user("hi")], &options, false);
749 assert_eq!(body["previous_response_id"], json!("resp_abc123"));
750 }
751
752 #[test]
753 fn build_body_max_tokens_becomes_max_output_tokens() {
754 let c = client();
755 let options = ChatOptions::new().with_max_tokens(256);
756 let body = c.build_body(&[user("hi")], &options, false);
757 assert_eq!(body["max_output_tokens"], json!(256));
758 assert!(body.get("max_tokens").is_none());
759 }
760
761 #[test]
762 fn build_body_stream_sets_stream_flag_without_stream_options() {
763 let c = client();
767 let body = c.build_body(&[user("hi")], &ChatOptions::new(), true);
768 assert_eq!(body["stream"], json!(true));
769 assert!(body.get("stream_options").is_none());
770 }
771
772 #[test]
777 fn parse_response_reuses_openai_responses_convert() {
778 let value = json!({
779 "id": "resp_abc123",
780 "model": "my-gpt4o-deployment",
781 "status": "completed",
782 "output": [{
783 "type": "message",
784 "role": "assistant",
785 "content": [{ "type": "output_text", "text": "Hello!" }],
786 }],
787 "usage": { "input_tokens": 10, "output_tokens": 5, "total_tokens": 15 },
788 });
789 let resp = agent_framework_openai::responses::parse_response(&value, None);
790 assert_eq!(resp.text(), "Hello!");
791 assert_eq!(resp.response_id.as_deref(), Some("resp_abc123"));
792 assert_eq!(resp.conversation_id.as_deref(), Some("resp_abc123"));
795 assert_eq!(resp.usage_details.unwrap().total_token_count, Some(15));
796 }
797
798 #[test]
812 fn from_env_reads_all_vars() {
813 let vars = [
814 ("AZURE_OPENAI_ENDPOINT", "https://res.openai.azure.com"),
815 ("AZURE_OPENAI_API_KEY", "test-key-123"),
816 (
817 "AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME",
818 "gpt-4o-responses-deployment",
819 ),
820 ("AZURE_OPENAI_API_VERSION", "2025-05-01-preview"),
821 (
822 "AZURE_OPENAI_BASE_URL",
823 "https://gateway.example.com/openai/v1/",
824 ),
825 ]
826 .into_iter()
827 .collect::<std::collections::HashMap<_, _>>();
828
829 let client =
830 AzureOpenAIResponsesClient::from_env_vars(|k| vars.get(k).map(|v| v.to_string()))
831 .unwrap();
832 assert_eq!(client.inner.endpoint, "https://res.openai.azure.com");
833 assert_eq!(client.inner.deployment, "gpt-4o-responses-deployment");
834 assert_eq!(
835 client.inner.api_version.as_deref(),
836 Some("2025-05-01-preview")
837 );
838 assert_eq!(
839 client.inner.base_url.as_deref(),
840 Some("https://gateway.example.com/openai/v1/")
841 );
842 assert!(matches!(client.inner.auth, Auth::ApiKey(ref k) if k == "test-key-123"));
843 }
844
845 #[test]
846 fn from_env_defaults_api_version_to_preview_when_unset() {
847 let vars = [
848 ("AZURE_OPENAI_ENDPOINT", "https://res.openai.azure.com"),
849 ("AZURE_OPENAI_API_KEY", "test-key-123"),
850 (
851 "AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME",
852 "gpt-4o-responses-deployment",
853 ),
854 ]
855 .into_iter()
856 .collect::<std::collections::HashMap<_, _>>();
857
858 let client =
859 AzureOpenAIResponsesClient::from_env_vars(|k| vars.get(k).map(|v| v.to_string()))
860 .unwrap();
861 assert_eq!(
862 client.inner.api_version.as_deref(),
863 Some(DEFAULT_API_VERSION)
864 );
865 assert_eq!(client.inner.base_url, None);
866 }
867
868 #[test]
869 fn from_env_errors_when_responses_deployment_missing() {
870 let vars = [
871 ("AZURE_OPENAI_ENDPOINT", "https://res.openai.azure.com"),
872 ("AZURE_OPENAI_API_KEY", "test-key-123"),
873 ]
874 .into_iter()
875 .collect::<std::collections::HashMap<_, _>>();
876
877 let err = AzureOpenAIResponsesClient::from_env_vars(|k| vars.get(k).map(|v| v.to_string()))
878 .unwrap_err();
879 assert!(err
880 .to_string()
881 .contains("AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"));
882 }
883
884 #[test]
885 fn from_env_errors_when_endpoint_missing() {
886 let vars = [
887 ("AZURE_OPENAI_API_KEY", "test-key-123"),
888 (
889 "AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME",
890 "gpt-4o-responses-deployment",
891 ),
892 ]
893 .into_iter()
894 .collect::<std::collections::HashMap<_, _>>();
895
896 let err = AzureOpenAIResponsesClient::from_env_vars(|k| vars.get(k).map(|v| v.to_string()))
897 .unwrap_err();
898 assert!(err.to_string().contains("AZURE_OPENAI_ENDPOINT"));
899 }
900
901 }