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