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    // Ensure tools and tool_resources always present (API may omit when empty)
392    flat.entry("tools".to_string())
393        .or_insert_with(|| Value::Array(Vec::new()));
394    flat.entry("tool_resources".to_string())
395        .or_insert_with(|| Value::Object(Map::new()));
396
397    Value::Object(flat)
398}
399
400#[cfg(test)]
401mod tests {
402    use super::*;
403    use crate::auth::{AuthError, AuthProvider};
404    use serde_json::json;
405
406    struct FakeAuth;
407    impl AuthProvider for FakeAuth {
408        fn get_token(&self) -> Result<String, AuthError> {
409            Ok("fake-token".to_string())
410        }
411        fn method_name(&self) -> &'static str {
412            "Fake"
413        }
414    }
415
416    fn make_client() -> FoundryClient {
417        FoundryClient::with_auth(
418            "https://my-ai-svc.services.ai.azure.com".to_string(),
419            "my-project".to_string(),
420            "2025-05-15-preview".to_string(),
421            Box::new(FakeAuth),
422        )
423        .unwrap()
424    }
425
426    #[test]
427    fn test_agents_url() {
428        let client = make_client();
429        let url = client.agents_url();
430        assert_eq!(
431            url,
432            "https://my-ai-svc.services.ai.azure.com/api/projects/my-project/agents?api-version=2025-05-15-preview"
433        );
434    }
435
436    #[test]
437    fn test_agent_url() {
438        let client = make_client();
439        let url = client.agent_url("Regulus");
440        assert_eq!(
441            url,
442            "https://my-ai-svc.services.ai.azure.com/api/projects/my-project/agents/Regulus?api-version=2025-05-15-preview"
443        );
444    }
445
446    #[test]
447    fn test_agent_versions_url() {
448        let client = make_client();
449        let url = client.agent_versions_url("KITT");
450        assert_eq!(
451            url,
452            "https://my-ai-svc.services.ai.azure.com/api/projects/my-project/agents/KITT/versions?api-version=2025-05-15-preview"
453        );
454    }
455
456    #[test]
457    fn test_auth_method() {
458        let client = make_client();
459        assert_eq!(client.auth_method(), "Fake");
460    }
461
462    #[test]
463    fn test_wrap_agent_payload() {
464        let flat = json!({
465            "id": "KITT",
466            "name": "KITT",
467            "model": "gpt-5.2-chat",
468            "kind": "prompt",
469            "instructions": "You are KITT.",
470            "tools": [{"type": "code_interpreter"}],
471            "metadata": {"logo": "kitt.svg"},
472            "description": "A smart car",
473            "version": "3",
474            "created_at": 1234567890
475        });
476
477        let wrapped = wrap_agent_payload(&flat);
478        let obj = wrapped.as_object().unwrap();
479
480        // Top level: metadata, description, definition
481        assert!(obj.contains_key("definition"));
482        assert!(obj.contains_key("metadata"));
483        assert!(obj.contains_key("description"));
484
485        // Excluded from payload
486        assert!(!obj.contains_key("id"));
487        assert!(!obj.contains_key("name"));
488        assert!(!obj.contains_key("version"));
489        assert!(!obj.contains_key("created_at"));
490
491        // Definition should contain model, instructions, tools, kind
492        let def = obj.get("definition").unwrap().as_object().unwrap();
493        assert_eq!(def.get("model").unwrap(), "gpt-5.2-chat");
494        assert_eq!(def.get("kind").unwrap(), "prompt");
495        assert_eq!(def.get("instructions").unwrap(), "You are KITT.");
496        assert!(def.get("tools").unwrap().as_array().unwrap().len() == 1);
497
498        // Definition should NOT contain excluded or version-level fields
499        assert!(!def.contains_key("id"));
500        assert!(!def.contains_key("name"));
501        assert!(!def.contains_key("metadata"));
502    }
503
504    #[test]
505    fn test_wrap_agent_payload_adds_default_kind() {
506        let flat = json!({
507            "name": "simple",
508            "model": "gpt-4o",
509            "instructions": "Be helpful."
510        });
511
512        let wrapped = wrap_agent_payload(&flat);
513        let def = wrapped.get("definition").unwrap().as_object().unwrap();
514        assert_eq!(def.get("kind").unwrap(), "prompt");
515    }
516
517    #[test]
518    fn test_flatten_then_wrap_roundtrip() {
519        let api_response = json!({
520            "object": "agent",
521            "id": "KITT",
522            "name": "KITT",
523            "versions": {
524                "latest": {
525                    "metadata": {"logo": "kitt.svg"},
526                    "version": "3",
527                    "description": "Smart car",
528                    "created_at": 1234567890,
529                    "definition": {
530                        "kind": "prompt",
531                        "model": "gpt-5.2-chat",
532                        "instructions": "You are KITT.",
533                        "tools": [{"type": "code_interpreter"}]
534                    }
535                }
536            }
537        });
538
539        let flat = flatten_agent_response(&api_response);
540        let wrapped = wrap_agent_payload(&flat);
541
542        // The wrapped payload should have a definition with the same content
543        let def = wrapped.get("definition").unwrap().as_object().unwrap();
544        assert_eq!(def.get("model").unwrap(), "gpt-5.2-chat");
545        assert_eq!(def.get("instructions").unwrap(), "You are KITT.");
546        assert_eq!(def.get("kind").unwrap(), "prompt");
547    }
548
549    #[test]
550    fn test_flatten_agent_response_full() {
551        let api_response = json!({
552            "object": "agent",
553            "id": "Regulus",
554            "name": "Regulus",
555            "versions": {
556                "latest": {
557                    "metadata": {
558                        "logo": "Avatar_Default.svg",
559                        "description": "",
560                        "modified_at": "1769974547"
561                    },
562                    "object": "agent.version",
563                    "id": "Regulus:5",
564                    "name": "Regulus",
565                    "version": "5",
566                    "description": "",
567                    "created_at": 1769974549,
568                    "definition": {
569                        "kind": "prompt",
570                        "model": "gpt-5.2-chat",
571                        "instructions": "You are Regulus.",
572                        "tools": [
573                            {"type": "mcp", "server_label": "kb_test"}
574                        ]
575                    }
576                }
577            }
578        });
579
580        let flat = flatten_agent_response(&api_response);
581        let obj = flat.as_object().unwrap();
582
583        assert_eq!(obj.get("id").unwrap(), "Regulus");
584        assert_eq!(obj.get("name").unwrap(), "Regulus");
585        assert_eq!(obj.get("model").unwrap(), "gpt-5.2-chat");
586        assert_eq!(obj.get("kind").unwrap(), "prompt");
587        assert_eq!(obj.get("instructions").unwrap(), "You are Regulus.");
588        assert_eq!(obj.get("version").unwrap(), "5");
589        assert_eq!(obj.get("description").unwrap(), "");
590        assert!(obj.get("metadata").is_some());
591        assert!(obj.get("tools").unwrap().as_array().unwrap().len() == 1);
592
593        // Should NOT have the nested versions structure
594        assert!(!obj.contains_key("versions"));
595        assert!(!obj.contains_key("object"));
596    }
597
598    #[test]
599    fn test_flatten_agent_response_minimal() {
600        let api_response = json!({
601            "object": "agent",
602            "id": "simple",
603            "name": "simple"
604        });
605
606        let flat = flatten_agent_response(&api_response);
607        let obj = flat.as_object().unwrap();
608
609        assert_eq!(obj.get("id").unwrap(), "simple");
610        assert_eq!(obj.get("name").unwrap(), "simple");
611        assert!(!obj.contains_key("model"));
612        // tools and tool_resources always present with defaults
613        assert_eq!(obj.get("tools").unwrap(), &json!([]));
614        assert_eq!(obj.get("tool_resources").unwrap(), &json!({}));
615    }
616
617    #[test]
618    fn test_flatten_agent_response_non_object() {
619        let flat = flatten_agent_response(&json!("not an object"));
620        assert_eq!(flat, json!("not an object"));
621    }
622}