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