Skip to main content

hoist_client/
foundry.rs

1//! Microsoft Foundry REST API client
2//!
3//! Manages Foundry agents via the project-scoped `/agents` API
4//! (new Foundry experience, API version `2025-05-15-preview`).
5
6use std::time::Duration;
7
8use reqwest::{Client, Method, StatusCode};
9use serde_json::{Map, Value};
10use tracing::{debug, instrument, warn};
11
12use hoist_core::config::FoundryServiceConfig;
13
14use crate::auth::{get_auth_provider_for, AuthProvider};
15use crate::error::ClientError;
16
17/// Maximum number of retry attempts for retryable errors
18const MAX_RETRIES: u32 = 3;
19
20/// Initial backoff delay in seconds
21const INITIAL_BACKOFF_SECS: u64 = 1;
22
23/// Microsoft Foundry API client
24pub struct FoundryClient {
25    http: Client,
26    auth: Box<dyn AuthProvider>,
27    base_url: String,
28    project: String,
29    api_version: String,
30}
31
32impl FoundryClient {
33    /// Create a new Foundry client from service configuration
34    pub fn new(config: &FoundryServiceConfig) -> Result<Self, ClientError> {
35        let auth = get_auth_provider_for(hoist_core::ServiceDomain::Foundry)?;
36        let http = Client::builder().timeout(Duration::from_secs(30)).build()?;
37
38        Ok(Self {
39            http,
40            auth,
41            base_url: config.service_url(),
42            project: config.project.clone(),
43            api_version: config.api_version.clone(),
44        })
45    }
46
47    /// Create with a custom auth provider (for testing)
48    pub fn with_auth(
49        base_url: String,
50        project: String,
51        api_version: String,
52        auth: Box<dyn AuthProvider>,
53    ) -> Result<Self, ClientError> {
54        let http = Client::builder().timeout(Duration::from_secs(30)).build()?;
55
56        Ok(Self {
57            http,
58            auth,
59            base_url,
60            project,
61            api_version,
62        })
63    }
64
65    /// Build URL for the agents collection
66    fn agents_url(&self) -> String {
67        format!(
68            "{}/api/projects/{}/agents?api-version={}",
69            self.base_url, self.project, self.api_version
70        )
71    }
72
73    /// Build URL for a specific agent
74    fn agent_url(&self, id: &str) -> String {
75        format!(
76            "{}/api/projects/{}/agents/{}?api-version={}",
77            self.base_url, self.project, id, self.api_version
78        )
79    }
80
81    /// Build URL for creating/updating agent versions
82    fn agent_versions_url(&self, name: &str) -> String {
83        format!(
84            "{}/api/projects/{}/agents/{}/versions?api-version={}",
85            self.base_url, self.project, name, self.api_version
86        )
87    }
88
89    /// Execute an HTTP request
90    async fn request(
91        &self,
92        method: Method,
93        url: &str,
94        body: Option<&Value>,
95    ) -> Result<Option<Value>, ClientError> {
96        let token = self.auth.get_token()?;
97
98        let mut request = self
99            .http
100            .request(method.clone(), url)
101            .header("Authorization", format!("Bearer {}", token))
102            .header("Content-Type", "application/json");
103
104        if let Some(json) = body {
105            request = request.json(json);
106        }
107
108        debug!("Request: {} {}", method, url);
109        let response = request.send().await?;
110        let status = response.status();
111
112        if status == StatusCode::NO_CONTENT {
113            return Ok(None);
114        }
115
116        let body = response.text().await?;
117
118        if status.is_success() {
119            if body.is_empty() {
120                Ok(None)
121            } else {
122                let value: Value = serde_json::from_str(&body)?;
123                Ok(Some(value))
124            }
125        } else {
126            match status {
127                StatusCode::NOT_FOUND => Err(ClientError::NotFound {
128                    kind: "agent".to_string(),
129                    name: url.to_string(),
130                }),
131                StatusCode::TOO_MANY_REQUESTS => {
132                    let retry_after = 60;
133                    Err(ClientError::RateLimited { retry_after })
134                }
135                StatusCode::SERVICE_UNAVAILABLE => Err(ClientError::ServiceUnavailable(body)),
136                _ => Err(ClientError::from_response_with_url(
137                    status.as_u16(),
138                    &body,
139                    Some(url),
140                )),
141            }
142        }
143    }
144
145    /// Execute an HTTP request with retry logic
146    async fn request_with_retry(
147        &self,
148        method: Method,
149        url: &str,
150        body: Option<&Value>,
151    ) -> Result<Option<Value>, ClientError> {
152        let mut attempt = 0u32;
153        loop {
154            match self.request(method.clone(), url, body).await {
155                Ok(value) => return Ok(value),
156                Err(err) if err.is_retryable() && attempt < MAX_RETRIES => {
157                    let delay = match &err {
158                        ClientError::RateLimited { retry_after } => {
159                            Duration::from_secs(*retry_after)
160                        }
161                        _ => Duration::from_secs(INITIAL_BACKOFF_SECS * 2u64.pow(attempt)),
162                    };
163                    warn!(
164                        "Request {} {} failed (attempt {}/{}): {}. Retrying in {:?}",
165                        method,
166                        url,
167                        attempt + 1,
168                        MAX_RETRIES + 1,
169                        err,
170                        delay,
171                    );
172                    tokio::time::sleep(delay).await;
173                    attempt += 1;
174                }
175                Err(err) => return Err(err),
176            }
177        }
178    }
179
180    /// List all agents in the project
181    #[instrument(skip(self))]
182    pub async fn list_agents(&self) -> Result<Vec<Value>, ClientError> {
183        let url = self.agents_url();
184        let response = self.request_with_retry(Method::GET, &url, None).await?;
185
186        match response {
187            Some(value) => {
188                let items = value
189                    .get("data")
190                    .and_then(|v| v.as_array())
191                    .cloned()
192                    .unwrap_or_default();
193                // Flatten versioned response into flat agent objects
194                Ok(items.iter().map(flatten_agent_response).collect())
195            }
196            None => Ok(Vec::new()),
197        }
198    }
199
200    /// Get a specific agent by ID
201    #[instrument(skip(self))]
202    pub async fn get_agent(&self, id: &str) -> Result<Value, ClientError> {
203        let url = self.agent_url(id);
204        let response = self.request_with_retry(Method::GET, &url, None).await?;
205
206        let raw = response.ok_or_else(|| ClientError::NotFound {
207            kind: "Agent".to_string(),
208            name: id.to_string(),
209        })?;
210        Ok(flatten_agent_response(&raw))
211    }
212
213    /// Create a new agent (creates first version)
214    ///
215    /// Takes a flat agent definition and wraps it in the API format
216    /// before posting to `/agents/{name}/versions`.
217    #[instrument(skip(self, definition))]
218    pub async fn create_agent(&self, definition: &Value) -> Result<Value, ClientError> {
219        let name = definition
220            .get("name")
221            .and_then(|n| n.as_str())
222            .ok_or_else(|| ClientError::Api {
223                status: 400,
224                message: "Agent definition missing 'name' field".to_string(),
225            })?;
226        let payload = wrap_agent_payload(definition);
227        let url = self.agent_versions_url(name);
228        let response = self
229            .request_with_retry(Method::POST, &url, Some(&payload))
230            .await?;
231
232        let raw = response.ok_or_else(|| ClientError::Api {
233            status: 500,
234            message: "No response body from agent creation".to_string(),
235        })?;
236        Ok(flatten_agent_response(&raw))
237    }
238
239    /// Update an existing agent (creates new version)
240    ///
241    /// Takes a flat agent definition and wraps it in the API format
242    /// before posting to `/agents/{name}/versions`.
243    #[instrument(skip(self, definition))]
244    pub async fn update_agent(&self, id: &str, definition: &Value) -> Result<Value, ClientError> {
245        let payload = wrap_agent_payload(definition);
246        let url = self.agent_versions_url(id);
247        let response = self
248            .request_with_retry(Method::POST, &url, Some(&payload))
249            .await?;
250
251        let raw = response.ok_or_else(|| ClientError::Api {
252            status: 500,
253            message: "No response body from agent update".to_string(),
254        })?;
255        Ok(flatten_agent_response(&raw))
256    }
257
258    /// Delete an agent
259    #[instrument(skip(self))]
260    pub async fn delete_agent(&self, id: &str) -> Result<(), ClientError> {
261        let url = self.agent_url(id);
262        self.request_with_retry(Method::DELETE, &url, None).await?;
263        Ok(())
264    }
265
266    /// Get the authentication method being used
267    pub fn auth_method(&self) -> &'static str {
268        self.auth.method_name()
269    }
270}
271
272/// Wrap a flat agent definition into the API request format.
273///
274/// Converts from flat: `{ "name", "model", "instructions", "tools", ... }`
275/// To API format:
276/// ```json
277/// {
278///   "metadata": {...},
279///   "description": "...",
280///   "definition": {
281///     "kind": "prompt",
282///     "model": "...",
283///     "instructions": "...",
284///     "tools": [...]
285///   }
286/// }
287/// ```
288fn wrap_agent_payload(flat: &Value) -> Value {
289    let obj = match flat.as_object() {
290        Some(o) => o,
291        None => return flat.clone(),
292    };
293
294    // Fields that go at the version level (outside definition)
295    const VERSION_LEVEL_FIELDS: &[&str] = &["metadata", "description"];
296
297    // Fields that are response-only and should not be sent
298    const EXCLUDED_FIELDS: &[&str] = &["id", "name", "version", "created_at", "object"];
299
300    let mut wrapper = Map::new();
301    let mut definition = Map::new();
302
303    for (key, value) in obj {
304        if EXCLUDED_FIELDS.contains(&key.as_str()) {
305            continue;
306        } else if VERSION_LEVEL_FIELDS.contains(&key.as_str()) {
307            wrapper.insert(key.clone(), value.clone());
308        } else {
309            definition.insert(key.clone(), value.clone());
310        }
311    }
312
313    // Ensure kind is set (default to "prompt")
314    if !definition.contains_key("kind") {
315        definition.insert("kind".to_string(), Value::String("prompt".to_string()));
316    }
317
318    wrapper.insert("definition".to_string(), Value::Object(definition));
319    Value::Object(wrapper)
320}
321
322/// Flatten a new Foundry agents API response into a flat structure
323/// compatible with the agent decomposition pipeline.
324///
325/// The new Foundry API returns a versioned structure:
326/// ```json
327/// {
328///   "object": "agent",
329///   "id": "MyAgent",
330///   "name": "MyAgent",
331///   "versions": {
332///     "latest": {
333///       "metadata": {...},
334///       "version": "5",
335///       "definition": {
336///         "kind": "prompt",
337///         "model": "gpt-5.2-chat",
338///         "instructions": "...",
339///         "tools": [...]
340///       }
341///     }
342///   }
343/// }
344/// ```
345///
346/// This flattens to: `{ "id", "name", "model", "instructions", "tools", ... }`
347fn flatten_agent_response(agent: &Value) -> Value {
348    let obj = match agent.as_object() {
349        Some(o) => o,
350        None => return agent.clone(),
351    };
352
353    let mut flat = Map::new();
354
355    // Top-level fields
356    if let Some(id) = obj.get("id") {
357        flat.insert("id".to_string(), id.clone());
358    }
359    if let Some(name) = obj.get("name") {
360        flat.insert("name".to_string(), name.clone());
361    }
362
363    // Extract from versions.latest
364    if let Some(latest) = obj
365        .get("versions")
366        .and_then(|v| v.get("latest"))
367        .and_then(|l| l.as_object())
368    {
369        // Version-level fields
370        if let Some(metadata) = latest.get("metadata") {
371            flat.insert("metadata".to_string(), metadata.clone());
372        }
373        if let Some(description) = latest.get("description") {
374            flat.insert("description".to_string(), description.clone());
375        }
376        if let Some(version) = latest.get("version") {
377            flat.insert("version".to_string(), version.clone());
378        }
379        if let Some(created_at) = latest.get("created_at") {
380            flat.insert("created_at".to_string(), created_at.clone());
381        }
382
383        // Definition-level fields (model, instructions, tools, kind, etc.)
384        if let Some(definition) = latest.get("definition").and_then(|d| d.as_object()) {
385            for (key, value) in definition {
386                flat.insert(key.clone(), value.clone());
387            }
388        }
389    }
390
391    Value::Object(flat)
392}
393
394#[cfg(test)]
395mod tests {
396    use super::*;
397    use crate::auth::{AuthError, AuthProvider};
398    use serde_json::json;
399
400    struct FakeAuth;
401    impl AuthProvider for FakeAuth {
402        fn get_token(&self) -> Result<String, AuthError> {
403            Ok("fake-token".to_string())
404        }
405        fn method_name(&self) -> &'static str {
406            "Fake"
407        }
408    }
409
410    fn make_client() -> FoundryClient {
411        FoundryClient::with_auth(
412            "https://my-ai-svc.services.ai.azure.com".to_string(),
413            "my-project".to_string(),
414            "2025-05-15-preview".to_string(),
415            Box::new(FakeAuth),
416        )
417        .unwrap()
418    }
419
420    #[test]
421    fn test_agents_url() {
422        let client = make_client();
423        let url = client.agents_url();
424        assert_eq!(
425            url,
426            "https://my-ai-svc.services.ai.azure.com/api/projects/my-project/agents?api-version=2025-05-15-preview"
427        );
428    }
429
430    #[test]
431    fn test_agent_url() {
432        let client = make_client();
433        let url = client.agent_url("Regulus");
434        assert_eq!(
435            url,
436            "https://my-ai-svc.services.ai.azure.com/api/projects/my-project/agents/Regulus?api-version=2025-05-15-preview"
437        );
438    }
439
440    #[test]
441    fn test_agent_versions_url() {
442        let client = make_client();
443        let url = client.agent_versions_url("KITT");
444        assert_eq!(
445            url,
446            "https://my-ai-svc.services.ai.azure.com/api/projects/my-project/agents/KITT/versions?api-version=2025-05-15-preview"
447        );
448    }
449
450    #[test]
451    fn test_auth_method() {
452        let client = make_client();
453        assert_eq!(client.auth_method(), "Fake");
454    }
455
456    #[test]
457    fn test_wrap_agent_payload() {
458        let flat = json!({
459            "id": "KITT",
460            "name": "KITT",
461            "model": "gpt-5.2-chat",
462            "kind": "prompt",
463            "instructions": "You are KITT.",
464            "tools": [{"type": "code_interpreter"}],
465            "metadata": {"logo": "kitt.svg"},
466            "description": "A smart car",
467            "version": "3",
468            "created_at": 1234567890
469        });
470
471        let wrapped = wrap_agent_payload(&flat);
472        let obj = wrapped.as_object().unwrap();
473
474        // Top level: metadata, description, definition
475        assert!(obj.contains_key("definition"));
476        assert!(obj.contains_key("metadata"));
477        assert!(obj.contains_key("description"));
478
479        // Excluded from payload
480        assert!(!obj.contains_key("id"));
481        assert!(!obj.contains_key("name"));
482        assert!(!obj.contains_key("version"));
483        assert!(!obj.contains_key("created_at"));
484
485        // Definition should contain model, instructions, tools, kind
486        let def = obj.get("definition").unwrap().as_object().unwrap();
487        assert_eq!(def.get("model").unwrap(), "gpt-5.2-chat");
488        assert_eq!(def.get("kind").unwrap(), "prompt");
489        assert_eq!(def.get("instructions").unwrap(), "You are KITT.");
490        assert!(def.get("tools").unwrap().as_array().unwrap().len() == 1);
491
492        // Definition should NOT contain excluded or version-level fields
493        assert!(!def.contains_key("id"));
494        assert!(!def.contains_key("name"));
495        assert!(!def.contains_key("metadata"));
496    }
497
498    #[test]
499    fn test_wrap_agent_payload_adds_default_kind() {
500        let flat = json!({
501            "name": "simple",
502            "model": "gpt-4o",
503            "instructions": "Be helpful."
504        });
505
506        let wrapped = wrap_agent_payload(&flat);
507        let def = wrapped.get("definition").unwrap().as_object().unwrap();
508        assert_eq!(def.get("kind").unwrap(), "prompt");
509    }
510
511    #[test]
512    fn test_flatten_then_wrap_roundtrip() {
513        let api_response = json!({
514            "object": "agent",
515            "id": "KITT",
516            "name": "KITT",
517            "versions": {
518                "latest": {
519                    "metadata": {"logo": "kitt.svg"},
520                    "version": "3",
521                    "description": "Smart car",
522                    "created_at": 1234567890,
523                    "definition": {
524                        "kind": "prompt",
525                        "model": "gpt-5.2-chat",
526                        "instructions": "You are KITT.",
527                        "tools": [{"type": "code_interpreter"}]
528                    }
529                }
530            }
531        });
532
533        let flat = flatten_agent_response(&api_response);
534        let wrapped = wrap_agent_payload(&flat);
535
536        // The wrapped payload should have a definition with the same content
537        let def = wrapped.get("definition").unwrap().as_object().unwrap();
538        assert_eq!(def.get("model").unwrap(), "gpt-5.2-chat");
539        assert_eq!(def.get("instructions").unwrap(), "You are KITT.");
540        assert_eq!(def.get("kind").unwrap(), "prompt");
541    }
542
543    #[test]
544    fn test_flatten_agent_response_full() {
545        let api_response = json!({
546            "object": "agent",
547            "id": "Regulus",
548            "name": "Regulus",
549            "versions": {
550                "latest": {
551                    "metadata": {
552                        "logo": "Avatar_Default.svg",
553                        "description": "",
554                        "modified_at": "1769974547"
555                    },
556                    "object": "agent.version",
557                    "id": "Regulus:5",
558                    "name": "Regulus",
559                    "version": "5",
560                    "description": "",
561                    "created_at": 1769974549,
562                    "definition": {
563                        "kind": "prompt",
564                        "model": "gpt-5.2-chat",
565                        "instructions": "You are Regulus.",
566                        "tools": [
567                            {"type": "mcp", "server_label": "kb_test"}
568                        ]
569                    }
570                }
571            }
572        });
573
574        let flat = flatten_agent_response(&api_response);
575        let obj = flat.as_object().unwrap();
576
577        assert_eq!(obj.get("id").unwrap(), "Regulus");
578        assert_eq!(obj.get("name").unwrap(), "Regulus");
579        assert_eq!(obj.get("model").unwrap(), "gpt-5.2-chat");
580        assert_eq!(obj.get("kind").unwrap(), "prompt");
581        assert_eq!(obj.get("instructions").unwrap(), "You are Regulus.");
582        assert_eq!(obj.get("version").unwrap(), "5");
583        assert_eq!(obj.get("description").unwrap(), "");
584        assert!(obj.get("metadata").is_some());
585        assert!(obj.get("tools").unwrap().as_array().unwrap().len() == 1);
586
587        // Should NOT have the nested versions structure
588        assert!(!obj.contains_key("versions"));
589        assert!(!obj.contains_key("object"));
590    }
591
592    #[test]
593    fn test_flatten_agent_response_minimal() {
594        let api_response = json!({
595            "object": "agent",
596            "id": "simple",
597            "name": "simple"
598        });
599
600        let flat = flatten_agent_response(&api_response);
601        let obj = flat.as_object().unwrap();
602
603        assert_eq!(obj.get("id").unwrap(), "simple");
604        assert_eq!(obj.get("name").unwrap(), "simple");
605        assert!(!obj.contains_key("model"));
606    }
607
608    #[test]
609    fn test_flatten_agent_response_non_object() {
610        let flat = flatten_agent_response(&json!("not an object"));
611        assert_eq!(flat, json!("not an object"));
612    }
613}