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 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 assert!(obj.contains_key("definition"));
482 assert!(obj.contains_key("metadata"));
483 assert!(obj.contains_key("description"));
484
485 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 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 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 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 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 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}