1use 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
17const MAX_RETRIES: u32 = 3;
19
20const INITIAL_BACKOFF_SECS: u64 = 1;
22
23pub 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 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 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 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 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 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 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 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 #[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 Ok(items.iter().map(flatten_agent_response).collect())
191 }
192 None => Ok(Vec::new()),
193 }
194 }
195
196 #[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 #[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 #[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 #[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 pub fn auth_method(&self) -> &'static str {
264 self.auth.method_name()
265 }
266}
267
268fn 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 const VERSION_LEVEL_FIELDS: &[&str] = &["metadata", "description"];
292
293 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 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
318fn 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 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 if let Some(latest) = obj
361 .get("versions")
362 .and_then(|v| v.get("latest"))
363 .and_then(|l| l.as_object())
364 {
365 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 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 assert!(obj.contains_key("definition"));
472 assert!(obj.contains_key("metadata"));
473 assert!(obj.contains_key("description"));
474
475 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 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 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 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 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}