1mod tool_definition_wire;
76
77use std::sync::Arc;
78
79use agent_framework_azure::responses::AzureOpenAIResponsesClient;
80use agent_framework_azure::TokenCredential;
81use agent_framework_core::agent::{Agent, AgentRunOptions, AgentRunStream, SupportsAgentRun};
82use agent_framework_core::client::{ChatClient, ChatStream};
83use agent_framework_core::error::{Error, Result};
84use agent_framework_core::session::AgentSession;
85use agent_framework_core::tools::ToolDefinition;
86use agent_framework_core::types::{AgentResponse, ChatOptions, ChatResponse, Message};
87use async_trait::async_trait;
88use serde::{Deserialize, Serialize};
89
90pub const FOUNDRY_SCOPE: &str = "https://ai.azure.com/.default";
99
100#[derive(Clone)]
114pub struct FoundryChatClient {
115 inner: AzureOpenAIResponsesClient,
116 endpoint: String,
117 model: String,
118}
119
120impl std::fmt::Debug for FoundryChatClient {
121 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
122 f.debug_struct("FoundryChatClient")
123 .field("endpoint", &self.endpoint)
124 .field("model", &self.model)
125 .finish_non_exhaustive()
126 }
127}
128
129impl FoundryChatClient {
130 pub fn new(
133 endpoint: impl Into<String>,
134 model: impl Into<String>,
135 api_key: impl Into<String>,
136 ) -> Self {
137 let endpoint = endpoint.into();
138 let model = model.into();
139 let inner = AzureOpenAIResponsesClient::new(endpoint.clone(), model.clone(), api_key)
140 .without_api_version()
141 .without_implicit_encrypted_reasoning();
142 Self {
143 inner,
144 endpoint,
145 model,
146 }
147 }
148
149 pub fn with_token_credential(
154 endpoint: impl Into<String>,
155 model: impl Into<String>,
156 credential: Arc<dyn TokenCredential>,
157 ) -> Self {
158 let endpoint = endpoint.into();
159 let model = model.into();
160 let inner = AzureOpenAIResponsesClient::with_token_credential(
161 endpoint.clone(),
162 model.clone(),
163 credential,
164 )
165 .without_api_version()
166 .without_implicit_encrypted_reasoning();
167 Self {
168 inner,
169 endpoint,
170 model,
171 }
172 }
173
174 pub fn from_env() -> Result<Self> {
189 Self::from_env_vars(|key| std::env::var(key).ok())
190 }
191
192 fn from_env_vars(get: impl Fn(&str) -> Option<String>) -> Result<Self> {
196 let endpoint = get("FOUNDRY_ENDPOINT")
197 .or_else(|| get("FOUNDRY_PROJECT_ENDPOINT"))
198 .ok_or_else(|| {
199 Error::Configuration(
200 "FOUNDRY_ENDPOINT (or FOUNDRY_PROJECT_ENDPOINT) is not set".into(),
201 )
202 })?;
203 let model = get("FOUNDRY_MODEL")
204 .ok_or_else(|| Error::Configuration("FOUNDRY_MODEL is not set".into()))?;
205 Ok(match get("FOUNDRY_API_KEY") {
206 Some(api_key) => Self::new(endpoint, model, api_key),
207 None => {
208 let credential: Arc<dyn TokenCredential> = Arc::new(
209 agent_framework_azure::DefaultAzureCredential::new(FOUNDRY_SCOPE),
210 );
211 Self::with_token_credential(endpoint, model, credential)
212 }
213 })
214 }
215
216 pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
219 self.inner = self.inner.with_base_url(base_url);
220 self
221 }
222
223 pub fn endpoint(&self) -> &str {
225 &self.endpoint
226 }
227
228 pub fn model_name(&self) -> &str {
230 &self.model
231 }
232}
233
234#[async_trait]
235impl ChatClient for FoundryChatClient {
236 async fn get_response(
237 &self,
238 messages: Vec<Message>,
239 options: ChatOptions,
240 ) -> Result<ChatResponse> {
241 self.inner.get_response(messages, options).await
242 }
243
244 async fn get_streaming_response(
245 &self,
246 messages: Vec<Message>,
247 options: ChatOptions,
248 ) -> Result<ChatStream> {
249 self.inner.get_streaming_response(messages, options).await
250 }
251
252 fn model(&self) -> Option<&str> {
253 self.inner.model()
254 }
255}
256
257#[derive(Debug, Clone, Serialize, Deserialize)]
278pub struct PromptAgentDefinition {
279 pub name: String,
281 pub model: String,
283 #[serde(default, skip_serializing_if = "Option::is_none")]
285 pub instructions: Option<String>,
286 #[serde(default, skip_serializing_if = "Option::is_none")]
288 pub description: Option<String>,
289 #[serde(
291 default,
292 skip_serializing_if = "Vec::is_empty",
293 with = "tool_definition_wire::vec"
294 )]
295 pub tools: Vec<ToolDefinition>,
296}
297
298impl PromptAgentDefinition {
299 pub fn new(name: impl Into<String>, model: impl Into<String>) -> Self {
302 Self {
303 name: name.into(),
304 model: model.into(),
305 instructions: None,
306 description: None,
307 tools: Vec::new(),
308 }
309 }
310}
311
312#[derive(Clone)]
335pub struct FoundryAgent {
336 inner: Agent,
337 definition: PromptAgentDefinition,
338}
339
340impl std::fmt::Debug for FoundryAgent {
341 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
342 f.debug_struct("FoundryAgent")
343 .field("definition", &self.definition)
344 .finish_non_exhaustive()
345 }
346}
347
348impl FoundryAgent {
349 pub fn builder(client: FoundryChatClient) -> FoundryAgentBuilder {
351 FoundryAgentBuilder::new(client)
352 }
353
354 pub fn from_definition(client: FoundryChatClient, definition: PromptAgentDefinition) -> Self {
360 let mut builder = Agent::builder(client)
361 .id(definition.name.clone())
362 .name(definition.name.clone())
363 .model(definition.model.clone())
364 .tools(definition.tools.clone());
365 if let Some(instructions) = &definition.instructions {
366 builder = builder.instructions(instructions.clone());
367 }
368 if let Some(description) = &definition.description {
369 builder = builder.description(description.clone());
370 }
371 Self {
372 inner: builder.build(),
373 definition,
374 }
375 }
376
377 pub fn to_prompt_agent(&self) -> PromptAgentDefinition {
380 self.definition.clone()
381 }
382
383 pub fn inner(&self) -> &Agent {
386 &self.inner
387 }
388}
389
390#[async_trait]
391impl SupportsAgentRun for FoundryAgent {
392 async fn run(
393 &self,
394 messages: Vec<Message>,
395 session: Option<&mut AgentSession>,
396 ) -> Result<AgentResponse> {
397 self.inner.run(messages, session).await
398 }
399
400 async fn run_with_options(
401 &self,
402 messages: Vec<Message>,
403 session: Option<&mut AgentSession>,
404 options: AgentRunOptions,
405 ) -> Result<AgentResponse> {
406 self.inner
407 .run_with_options(messages, session, options)
408 .await
409 }
410
411 async fn run_stream(
412 &self,
413 messages: Vec<Message>,
414 session: Option<AgentSession>,
415 options: Option<AgentRunOptions>,
416 ) -> Result<AgentRunStream> {
417 self.inner.run_stream(messages, session, options).await
418 }
419
420 fn id(&self) -> &str {
421 self.inner.id()
422 }
423
424 fn name(&self) -> Option<&str> {
425 self.inner.name()
426 }
427
428 fn display_name(&self) -> String {
429 self.inner.display_name()
430 }
431
432 fn create_session(&self) -> AgentSession {
433 self.inner.create_session()
434 }
435}
436
437pub struct FoundryAgentBuilder {
440 client: FoundryChatClient,
441 name: Option<String>,
442 model: String,
443 instructions: Option<String>,
444 description: Option<String>,
445 tools: Vec<ToolDefinition>,
446}
447
448impl FoundryAgentBuilder {
449 fn new(client: FoundryChatClient) -> Self {
450 let model = client.model_name().to_string();
451 Self {
452 client,
453 name: None,
454 model,
455 instructions: None,
456 description: None,
457 tools: Vec::new(),
458 }
459 }
460
461 pub fn name(mut self, name: impl Into<String>) -> Self {
463 self.name = Some(name.into());
464 self
465 }
466
467 pub fn model(mut self, model: impl Into<String>) -> Self {
470 self.model = model.into();
471 self
472 }
473
474 pub fn instructions(mut self, instructions: impl Into<String>) -> Self {
476 self.instructions = Some(instructions.into());
477 self
478 }
479
480 pub fn description(mut self, description: impl Into<String>) -> Self {
482 self.description = Some(description.into());
483 self
484 }
485
486 pub fn tool(mut self, tool: ToolDefinition) -> Self {
488 self.tools.push(tool);
489 self
490 }
491
492 pub fn tools(mut self, tools: impl IntoIterator<Item = ToolDefinition>) -> Self {
494 self.tools.extend(tools);
495 self
496 }
497
498 pub fn build(self) -> FoundryAgent {
500 let name = self.name.unwrap_or_else(|| self.model.clone());
501 let definition = PromptAgentDefinition {
502 name,
503 model: self.model,
504 instructions: self.instructions,
505 description: self.description,
506 tools: self.tools,
507 };
508 FoundryAgent::from_definition(self.client, definition)
509 }
510}
511
512#[cfg(test)]
513mod tests {
514 use super::*;
515 use agent_framework_azure::StaticTokenCredential;
516 use agent_framework_core::tools::{hosted_code_interpreter, FunctionTool};
517 use serde_json::json;
518
519 fn client() -> FoundryChatClient {
520 FoundryChatClient::with_token_credential(
521 "https://my-project.services.ai.azure.com",
522 "gpt-4o",
523 Arc::new(StaticTokenCredential::new("test-token")),
524 )
525 }
526
527 #[test]
528 fn client_reports_endpoint_and_model() {
529 let c = client();
530 assert_eq!(c.endpoint(), "https://my-project.services.ai.azure.com");
531 assert_eq!(c.model_name(), "gpt-4o");
532 assert_eq!(c.model(), Some("gpt-4o"));
533 }
534
535 #[test]
538 fn from_env_errors_without_endpoint() {
539 let err = FoundryChatClient::from_env_vars(|key| match key {
540 "FOUNDRY_MODEL" => Some("gpt-4o".into()),
541 _ => None,
542 })
543 .unwrap_err();
544 assert!(matches!(err, Error::Configuration(_)), "{err:?}");
545 assert!(err.to_string().contains("FOUNDRY_ENDPOINT"));
546 }
547
548 #[test]
549 fn from_env_errors_without_model() {
550 let err = FoundryChatClient::from_env_vars(|key| match key {
551 "FOUNDRY_ENDPOINT" => Some("https://proj.services.ai.azure.com".into()),
552 _ => None,
553 })
554 .unwrap_err();
555 assert!(matches!(err, Error::Configuration(_)), "{err:?}");
556 assert!(err.to_string().contains("FOUNDRY_MODEL"));
557 }
558
559 #[test]
560 fn from_env_accepts_project_endpoint_alias() {
561 let c = FoundryChatClient::from_env_vars(|key| match key {
562 "FOUNDRY_PROJECT_ENDPOINT" => Some("https://proj.services.ai.azure.com".into()),
563 "FOUNDRY_MODEL" => Some("gpt-4o".into()),
564 "FOUNDRY_API_KEY" => Some("key-123".into()),
565 _ => None,
566 })
567 .unwrap();
568 assert_eq!(c.endpoint(), "https://proj.services.ai.azure.com");
569 assert_eq!(c.model_name(), "gpt-4o");
570 }
571
572 #[test]
573 fn from_env_with_api_key_authenticates_by_key() {
574 let c = FoundryChatClient::from_env_vars(|key| match key {
575 "FOUNDRY_ENDPOINT" => Some("https://proj.services.ai.azure.com".into()),
576 "FOUNDRY_MODEL" => Some("gpt-4o".into()),
577 "FOUNDRY_API_KEY" => Some("key-123".into()),
578 _ => None,
579 })
580 .unwrap();
581 assert_eq!(c.model_name(), "gpt-4o");
582 }
583
584 #[test]
585 fn from_env_without_api_key_falls_back_to_default_azure_credential() {
586 let c = FoundryChatClient::from_env_vars(|key| match key {
589 "FOUNDRY_ENDPOINT" => Some("https://proj.services.ai.azure.com".into()),
590 "FOUNDRY_MODEL" => Some("gpt-4o".into()),
591 _ => None,
592 })
593 .unwrap();
594 assert_eq!(c.endpoint(), "https://proj.services.ai.azure.com");
595 }
596
597 #[test]
600 fn prompt_agent_definition_round_trips_through_json() {
601 let tool = FunctionTool::new(
602 "get_weather",
603 "Get the weather",
604 json!({"type": "object", "properties": {}}),
605 |_| async { Ok(json!("ok")) },
606 )
607 .into_definition();
608 let definition = PromptAgentDefinition {
609 name: "weather-bot".into(),
610 model: "gpt-4o".into(),
611 instructions: Some("Be terse.".into()),
612 description: Some("Answers weather questions.".into()),
613 tools: vec![tool, hosted_code_interpreter()],
614 };
615
616 let json = serde_json::to_string(&definition).unwrap();
617 let back: PromptAgentDefinition = serde_json::from_str(&json).unwrap();
618
619 assert_eq!(back.name, "weather-bot");
620 assert_eq!(back.model, "gpt-4o");
621 assert_eq!(back.instructions.as_deref(), Some("Be terse."));
622 assert_eq!(
623 back.description.as_deref(),
624 Some("Answers weather questions.")
625 );
626 assert_eq!(back.tools.len(), 2);
627 assert_eq!(back.tools[0].name, "get_weather");
628 assert!(back.tools[0].executor.is_none());
629 }
630
631 #[test]
632 fn prompt_agent_definition_omits_empty_optional_fields() {
633 let definition = PromptAgentDefinition::new("bare", "gpt-4o");
634 let json = serde_json::to_value(&definition).unwrap();
635 assert!(json.get("instructions").is_none());
636 assert!(json.get("description").is_none());
637 assert!(json.get("tools").is_none());
638 }
639
640 #[test]
643 fn from_definition_to_prompt_agent_round_trips() {
644 let definition = PromptAgentDefinition {
645 name: "rust-example-agent".into(),
646 model: "gpt-4o".into(),
647 instructions: Some("You are concise.".into()),
648 description: Some("An example agent.".into()),
649 tools: vec![hosted_code_interpreter()],
650 };
651 let agent = FoundryAgent::from_definition(client(), definition.clone());
652
653 let round_tripped = agent.to_prompt_agent();
654 assert_eq!(round_tripped.name, definition.name);
655 assert_eq!(round_tripped.model, definition.model);
656 assert_eq!(round_tripped.instructions, definition.instructions);
657 assert_eq!(round_tripped.description, definition.description);
658 assert_eq!(round_tripped.tools.len(), 1);
659
660 assert_eq!(agent.id(), "rust-example-agent");
661 assert_eq!(agent.name(), Some("rust-example-agent"));
662 }
663
664 #[test]
665 fn builder_defaults_name_to_the_client_model() {
666 let agent = FoundryAgent::builder(client()).build();
667 assert_eq!(agent.to_prompt_agent().name, "gpt-4o");
668 assert_eq!(agent.to_prompt_agent().model, "gpt-4o");
669 }
670
671 #[test]
672 fn builder_collects_instructions_description_and_tools() {
673 let agent = FoundryAgent::builder(client())
674 .name("named-agent")
675 .instructions("Be helpful.")
676 .description("A test agent.")
677 .tool(hosted_code_interpreter())
678 .build();
679 let definition = agent.to_prompt_agent();
680 assert_eq!(definition.name, "named-agent");
681 assert_eq!(definition.instructions.as_deref(), Some("Be helpful."));
682 assert_eq!(definition.description.as_deref(), Some("A test agent."));
683 assert_eq!(definition.tools.len(), 1);
684 }
685}