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_with_url(
137 status.as_u16(),
138 &body,
139 Some(url),
140 )),
141 }
142 }
143 }
144
145 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 #[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 Ok(items.iter().map(flatten_agent_response).collect())
195 }
196 None => Ok(Vec::new()),
197 }
198 }
199
200 #[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 #[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 #[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 #[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 pub fn auth_method(&self) -> &'static str {
268 self.auth.method_name()
269 }
270}
271
272fn 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 const VERSION_LEVEL_FIELDS: &[&str] = &["metadata", "description"];
296
297 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 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
322fn 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 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 if let Some(latest) = obj
365 .get("versions")
366 .and_then(|v| v.get("latest"))
367 .and_then(|l| l.as_object())
368 {
369 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 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 assert!(obj.contains_key("definition"));
476 assert!(obj.contains_key("metadata"));
477 assert!(obj.contains_key("description"));
478
479 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 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 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 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 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}