1use std::time::Duration;
7
8use reqwest::{Client, Method, StatusCode};
9use serde_json::{Map, Value};
10use tracing::{debug, instrument, warn};
11
12use rigg_core::config::FoundryServiceConfig;
13
14use crate::auth::{AuthProvider, get_auth_provider_for};
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 features: Vec<String>,
33}
34
35impl FoundryClient {
36 pub fn from_connection(
38 conn: &rigg_core::workspace::FoundryConnection,
39 ) -> Result<Self, ClientError> {
40 let auth = get_auth_provider_for(rigg_core::ServiceDomain::Foundry)?;
41 let http = Client::builder().timeout(Duration::from_secs(30)).build()?;
42 Ok(Self {
43 http,
44 auth,
45 base_url: conn
46 .endpoint
47 .clone()
48 .map(|e| e.trim_end_matches('/').to_string())
49 .unwrap_or_else(|| format!("https://{}.services.ai.azure.com", conn.account)),
50 project: conn.project.clone(),
51 api_version: conn
52 .api_version
53 .clone()
54 .unwrap_or_else(|| rigg_core::registry::FOUNDRY_API_VERSION.to_string()),
55 features: Vec::new(),
56 })
57 }
58
59 pub fn new(config: &FoundryServiceConfig) -> Result<Self, ClientError> {
61 let auth = get_auth_provider_for(rigg_core::ServiceDomain::Foundry)?;
62 let http = Client::builder().timeout(Duration::from_secs(30)).build()?;
63
64 Ok(Self {
65 http,
66 auth,
67 base_url: config.service_url(),
68 project: config.project.clone(),
69 api_version: rigg_core::registry::FOUNDRY_API_VERSION.to_string(),
70 features: Vec::new(),
71 })
72 }
73
74 pub fn with_auth(
76 base_url: String,
77 project: String,
78 api_version: String,
79 auth: Box<dyn AuthProvider>,
80 ) -> Result<Self, ClientError> {
81 let http = Client::builder().timeout(Duration::from_secs(30)).build()?;
82
83 Ok(Self {
84 http,
85 auth,
86 base_url,
87 project,
88 api_version,
89 features: Vec::new(),
90 })
91 }
92
93 pub fn with_features(mut self, features: &[&str]) -> Self {
95 self.features = features.iter().map(|s| s.to_string()).collect();
96 self
97 }
98
99 fn agents_url(&self) -> String {
101 format!(
102 "{}/api/projects/{}/agents?api-version={}",
103 self.base_url, self.project, self.api_version
104 )
105 }
106
107 fn agent_url(&self, id: &str) -> String {
109 format!(
110 "{}/api/projects/{}/agents/{}?api-version={}",
111 self.base_url,
112 self.project,
113 urlencoding::encode(id),
114 self.api_version
115 )
116 }
117
118 fn agent_versions_url(&self, name: &str) -> String {
120 format!(
121 "{}/api/projects/{}/agents/{}/versions?api-version={}",
122 self.base_url,
123 self.project,
124 urlencoding::encode(name),
125 self.api_version
126 )
127 }
128
129 async fn request(
131 &self,
132 method: Method,
133 url: &str,
134 body: Option<&Value>,
135 ) -> Result<Option<Value>, ClientError> {
136 let token = self.auth.get_token()?;
137
138 let mut request = self
139 .http
140 .request(method.clone(), url)
141 .header("Authorization", format!("Bearer {}", token))
142 .header("Content-Type", "application/json");
143
144 if !self.features.is_empty() {
145 request = request.header("Foundry-Features", self.features.join(","));
146 }
147
148 if let Some(json) = body {
149 request = request.json(json);
150 }
151
152 debug!("Request: {} {}", method, url);
153 let response = request.send().await?;
154 let status = response.status();
155
156 if status == StatusCode::NO_CONTENT {
157 return Ok(None);
158 }
159
160 let body = response.text().await?;
161
162 if status.is_success() {
163 if body.is_empty() {
164 Ok(None)
165 } else {
166 let value: Value = serde_json::from_str(&body)?;
167 Ok(Some(value))
168 }
169 } else {
170 match status {
171 StatusCode::NOT_FOUND => Err(ClientError::NotFound {
172 kind: "agent".to_string(),
173 name: url.to_string(),
174 }),
175 StatusCode::TOO_MANY_REQUESTS => {
176 let retry_after = 60;
177 Err(ClientError::RateLimited { retry_after })
178 }
179 StatusCode::SERVICE_UNAVAILABLE => Err(ClientError::ServiceUnavailable(body)),
180 _ => Err(ClientError::from_response_with_url(
181 status.as_u16(),
182 &body,
183 Some(url),
184 )),
185 }
186 }
187 }
188
189 async fn request_with_retry(
191 &self,
192 method: Method,
193 url: &str,
194 body: Option<&Value>,
195 ) -> Result<Option<Value>, ClientError> {
196 let mut attempt = 0u32;
197 loop {
198 match self.request(method.clone(), url, body).await {
199 Ok(value) => return Ok(value),
200 Err(err) if err.is_retryable() && attempt < MAX_RETRIES => {
201 let delay = match &err {
202 ClientError::RateLimited { retry_after } => {
203 Duration::from_secs(*retry_after)
204 }
205 _ => Duration::from_secs(INITIAL_BACKOFF_SECS * 2u64.pow(attempt)),
206 };
207 warn!(
208 "Request {} {} failed (attempt {}/{}): {}. Retrying in {:?}",
209 method,
210 url,
211 attempt + 1,
212 MAX_RETRIES + 1,
213 err,
214 delay,
215 );
216 tokio::time::sleep(delay).await;
217 attempt += 1;
218 }
219 Err(err) => return Err(err),
220 }
221 }
222 }
223
224 #[instrument(skip(self))]
226 pub async fn list_agents(&self) -> Result<Vec<Value>, ClientError> {
227 let url = self.agents_url();
228 let response = self.request_with_retry(Method::GET, &url, None).await?;
229
230 match response {
231 Some(value) => {
232 let items = value
233 .get("data")
234 .and_then(|v| v.as_array())
235 .cloned()
236 .unwrap_or_default();
237 Ok(items.iter().map(flatten_agent_response).collect())
239 }
240 None => Ok(Vec::new()),
241 }
242 }
243
244 #[instrument(skip(self))]
246 pub async fn get_agent(&self, id: &str) -> Result<Value, ClientError> {
247 let url = self.agent_url(id);
248 let response = self.request_with_retry(Method::GET, &url, None).await?;
249
250 let raw = response.ok_or_else(|| ClientError::NotFound {
251 kind: "Agent".to_string(),
252 name: id.to_string(),
253 })?;
254 Ok(flatten_agent_response(&raw))
255 }
256
257 #[instrument(skip(self, definition))]
262 pub async fn create_agent(&self, definition: &Value) -> Result<Value, ClientError> {
263 let name = definition
264 .get("name")
265 .and_then(|n| n.as_str())
266 .ok_or_else(|| ClientError::Api {
267 status: 400,
268 message: "Agent definition missing 'name' field".to_string(),
269 })?;
270 let payload = wrap_agent_payload(definition);
271 let url = self.agent_versions_url(name);
272 let response = self
273 .request_with_retry(Method::POST, &url, Some(&payload))
274 .await?;
275
276 let raw = response.ok_or_else(|| ClientError::Api {
277 status: 500,
278 message: "No response body from agent creation".to_string(),
279 })?;
280 Ok(flatten_agent_response(&raw))
281 }
282
283 #[instrument(skip(self, definition))]
288 pub async fn update_agent(&self, id: &str, definition: &Value) -> Result<Value, ClientError> {
289 let payload = wrap_agent_payload(definition);
290 let url = self.agent_versions_url(id);
291 let response = self
292 .request_with_retry(Method::POST, &url, Some(&payload))
293 .await?;
294
295 let raw = response.ok_or_else(|| ClientError::Api {
296 status: 500,
297 message: "No response body from agent update".to_string(),
298 })?;
299 Ok(flatten_agent_response(&raw))
300 }
301
302 #[instrument(skip(self))]
304 pub async fn delete_agent(&self, id: &str) -> Result<(), ClientError> {
305 let url = self.agent_url(id);
306 self.request_with_retry(Method::DELETE, &url, None).await?;
307 Ok(())
308 }
309
310 pub fn auth_method(&self) -> &'static str {
312 self.auth.method_name()
313 }
314}
315
316fn wrap_agent_payload(flat: &Value) -> Value {
333 let obj = match flat.as_object() {
334 Some(o) => o,
335 None => return flat.clone(),
336 };
337
338 const VERSION_LEVEL_FIELDS: &[&str] = &["metadata", "description"];
340
341 const EXCLUDED_FIELDS: &[&str] = &["id", "name", "version", "created_at", "object"];
343
344 let mut wrapper = Map::new();
345 let mut definition = Map::new();
346
347 for (key, value) in obj {
348 if EXCLUDED_FIELDS.contains(&key.as_str()) {
349 continue;
350 } else if VERSION_LEVEL_FIELDS.contains(&key.as_str()) {
351 wrapper.insert(key.clone(), value.clone());
352 } else {
353 definition.insert(key.clone(), value.clone());
354 }
355 }
356
357 if !definition.contains_key("kind") {
359 definition.insert("kind".to_string(), Value::String("prompt".to_string()));
360 }
361
362 wrapper.insert("definition".to_string(), Value::Object(definition));
363 Value::Object(wrapper)
364}
365
366fn flatten_agent_response(agent: &Value) -> Value {
392 let obj = match agent.as_object() {
393 Some(o) => o,
394 None => return agent.clone(),
395 };
396
397 let mut flat = Map::new();
398
399 if let Some(id) = obj.get("id") {
401 flat.insert("id".to_string(), id.clone());
402 }
403 if let Some(name) = obj.get("name") {
404 flat.insert("name".to_string(), name.clone());
405 }
406
407 if let Some(latest) = obj
409 .get("versions")
410 .and_then(|v| v.get("latest"))
411 .and_then(|l| l.as_object())
412 {
413 if let Some(metadata) = latest.get("metadata") {
415 flat.insert("metadata".to_string(), metadata.clone());
416 }
417 if let Some(description) = latest.get("description") {
418 flat.insert("description".to_string(), description.clone());
419 }
420 if let Some(version) = latest.get("version") {
421 flat.insert("version".to_string(), version.clone());
422 }
423 if let Some(created_at) = latest.get("created_at") {
424 flat.insert("created_at".to_string(), created_at.clone());
425 }
426
427 if let Some(definition) = latest.get("definition").and_then(|d| d.as_object()) {
429 for (key, value) in definition {
430 flat.insert(key.clone(), value.clone());
431 }
432 }
433 }
434
435 flat.entry("tools".to_string())
437 .or_insert_with(|| Value::Array(Vec::new()));
438 flat.entry("tool_resources".to_string())
439 .or_insert_with(|| Value::Object(Map::new()));
440
441 Value::Object(flat)
442}
443
444#[cfg(test)]
445mod tests {
446 use super::*;
447 use crate::auth::{AuthError, AuthProvider};
448 use serde_json::json;
449
450 struct FakeAuth;
451 impl AuthProvider for FakeAuth {
452 fn get_token(&self) -> Result<String, AuthError> {
453 Ok("fake-token".to_string())
454 }
455 fn method_name(&self) -> &'static str {
456 "Fake"
457 }
458 }
459
460 fn make_client() -> FoundryClient {
461 FoundryClient::with_auth(
462 "https://my-ai-svc.services.ai.azure.com".to_string(),
463 "my-project".to_string(),
464 "v1".to_string(),
465 Box::new(FakeAuth),
466 )
467 .unwrap()
468 }
469
470 #[test]
471 fn test_agents_url() {
472 let client = make_client();
473 let url = client.agents_url();
474 assert_eq!(
475 url,
476 "https://my-ai-svc.services.ai.azure.com/api/projects/my-project/agents?api-version=v1"
477 );
478 }
479
480 #[test]
481 fn test_agent_url() {
482 let client = make_client();
483 let url = client.agent_url("Regulus");
484 assert_eq!(
485 url,
486 "https://my-ai-svc.services.ai.azure.com/api/projects/my-project/agents/Regulus?api-version=v1"
487 );
488 }
489
490 #[test]
491 fn test_agent_versions_url() {
492 let client = make_client();
493 let url = client.agent_versions_url("KITT");
494 assert_eq!(
495 url,
496 "https://my-ai-svc.services.ai.azure.com/api/projects/my-project/agents/KITT/versions?api-version=v1"
497 );
498 }
499
500 #[test]
501 fn test_auth_method() {
502 let client = make_client();
503 assert_eq!(client.auth_method(), "Fake");
504 }
505
506 #[test]
507 fn test_wrap_agent_payload() {
508 let flat = json!({
509 "id": "KITT",
510 "name": "KITT",
511 "model": "gpt-5.2-chat",
512 "kind": "prompt",
513 "instructions": "You are KITT.",
514 "tools": [{"type": "code_interpreter"}],
515 "metadata": {"logo": "kitt.svg"},
516 "description": "A smart car",
517 "version": "3",
518 "created_at": 1234567890
519 });
520
521 let wrapped = wrap_agent_payload(&flat);
522 let obj = wrapped.as_object().unwrap();
523
524 assert!(obj.contains_key("definition"));
526 assert!(obj.contains_key("metadata"));
527 assert!(obj.contains_key("description"));
528
529 assert!(!obj.contains_key("id"));
531 assert!(!obj.contains_key("name"));
532 assert!(!obj.contains_key("version"));
533 assert!(!obj.contains_key("created_at"));
534
535 let def = obj.get("definition").unwrap().as_object().unwrap();
537 assert_eq!(def.get("model").unwrap(), "gpt-5.2-chat");
538 assert_eq!(def.get("kind").unwrap(), "prompt");
539 assert_eq!(def.get("instructions").unwrap(), "You are KITT.");
540 assert!(def.get("tools").unwrap().as_array().unwrap().len() == 1);
541
542 assert!(!def.contains_key("id"));
544 assert!(!def.contains_key("name"));
545 assert!(!def.contains_key("metadata"));
546 }
547
548 #[test]
549 fn test_wrap_agent_payload_adds_default_kind() {
550 let flat = json!({
551 "name": "simple",
552 "model": "gpt-4o",
553 "instructions": "Be helpful."
554 });
555
556 let wrapped = wrap_agent_payload(&flat);
557 let def = wrapped.get("definition").unwrap().as_object().unwrap();
558 assert_eq!(def.get("kind").unwrap(), "prompt");
559 }
560
561 #[test]
562 fn test_flatten_then_wrap_roundtrip() {
563 let api_response = json!({
564 "object": "agent",
565 "id": "KITT",
566 "name": "KITT",
567 "versions": {
568 "latest": {
569 "metadata": {"logo": "kitt.svg"},
570 "version": "3",
571 "description": "Smart car",
572 "created_at": 1234567890,
573 "definition": {
574 "kind": "prompt",
575 "model": "gpt-5.2-chat",
576 "instructions": "You are KITT.",
577 "tools": [{"type": "code_interpreter"}]
578 }
579 }
580 }
581 });
582
583 let flat = flatten_agent_response(&api_response);
584 let wrapped = wrap_agent_payload(&flat);
585
586 let def = wrapped.get("definition").unwrap().as_object().unwrap();
588 assert_eq!(def.get("model").unwrap(), "gpt-5.2-chat");
589 assert_eq!(def.get("instructions").unwrap(), "You are KITT.");
590 assert_eq!(def.get("kind").unwrap(), "prompt");
591 }
592
593 #[test]
594 fn test_flatten_agent_response_full() {
595 let api_response = json!({
596 "object": "agent",
597 "id": "Regulus",
598 "name": "Regulus",
599 "versions": {
600 "latest": {
601 "metadata": {
602 "logo": "Avatar_Default.svg",
603 "description": "",
604 "modified_at": "1769974547"
605 },
606 "object": "agent.version",
607 "id": "Regulus:5",
608 "name": "Regulus",
609 "version": "5",
610 "description": "",
611 "created_at": 1769974549,
612 "definition": {
613 "kind": "prompt",
614 "model": "gpt-5.2-chat",
615 "instructions": "You are Regulus.",
616 "tools": [
617 {"type": "mcp", "server_label": "kb_test"}
618 ]
619 }
620 }
621 }
622 });
623
624 let flat = flatten_agent_response(&api_response);
625 let obj = flat.as_object().unwrap();
626
627 assert_eq!(obj.get("id").unwrap(), "Regulus");
628 assert_eq!(obj.get("name").unwrap(), "Regulus");
629 assert_eq!(obj.get("model").unwrap(), "gpt-5.2-chat");
630 assert_eq!(obj.get("kind").unwrap(), "prompt");
631 assert_eq!(obj.get("instructions").unwrap(), "You are Regulus.");
632 assert_eq!(obj.get("version").unwrap(), "5");
633 assert_eq!(obj.get("description").unwrap(), "");
634 assert!(obj.get("metadata").is_some());
635 assert!(obj.get("tools").unwrap().as_array().unwrap().len() == 1);
636
637 assert!(!obj.contains_key("versions"));
639 assert!(!obj.contains_key("object"));
640 }
641
642 #[test]
643 fn test_flatten_agent_response_minimal() {
644 let api_response = json!({
645 "object": "agent",
646 "id": "simple",
647 "name": "simple"
648 });
649
650 let flat = flatten_agent_response(&api_response);
651 let obj = flat.as_object().unwrap();
652
653 assert_eq!(obj.get("id").unwrap(), "simple");
654 assert_eq!(obj.get("name").unwrap(), "simple");
655 assert!(!obj.contains_key("model"));
656 assert_eq!(obj.get("tools").unwrap(), &json!([]));
658 assert_eq!(obj.get("tool_resources").unwrap(), &json!({}));
659 }
660
661 #[test]
662 fn test_flatten_agent_response_non_object() {
663 let flat = flatten_agent_response(&json!("not an object"));
664 assert_eq!(flat, json!("not an object"));
665 }
666
667 #[test]
668 fn test_wrap_flatten_roundtrip_preserves_tool_permissions() {
669 let flat = json!({
670 "name": "test-agent",
671 "kind": "prompt",
672 "model": "gpt-4o",
673 "tools": [{
674 "type": "mcp",
675 "server_label": "kb_test",
676 "require_approval": "never",
677 "allowed_tools": ["tool_a", "tool_b"]
678 }]
679 });
680
681 let wrapped = wrap_agent_payload(&flat);
683 let def_tools = wrapped["definition"]["tools"].as_array().unwrap();
684 assert_eq!(def_tools[0]["require_approval"], "never");
685 assert_eq!(def_tools[0]["allowed_tools"][0], "tool_a");
686 assert_eq!(def_tools[0]["allowed_tools"][1], "tool_b");
687
688 let api_response = json!({
690 "object": "agent",
691 "id": "test-agent",
692 "name": "test-agent",
693 "versions": {
694 "latest": {
695 "version": "1",
696 "definition": {
697 "kind": "prompt",
698 "model": "gpt-4o",
699 "tools": [{
700 "type": "mcp",
701 "server_label": "kb_test",
702 "require_approval": "never",
703 "allowed_tools": ["tool_a", "tool_b"]
704 }]
705 }
706 }
707 }
708 });
709
710 let flattened = flatten_agent_response(&api_response);
711 let tools = flattened["tools"].as_array().unwrap();
712 assert_eq!(tools[0]["require_approval"], "never");
713 let allowed = tools[0]["allowed_tools"].as_array().unwrap();
714 assert_eq!(allowed.len(), 2);
715 assert_eq!(allowed[0], "tool_a");
716 assert_eq!(allowed[1], "tool_b");
717 }
718
719 #[test]
720 fn test_flatten_preserves_require_approval_object_form() {
721 let api_response = json!({
722 "object": "agent",
723 "id": "granular-agent",
724 "name": "granular-agent",
725 "versions": {
726 "latest": {
727 "version": "1",
728 "definition": {
729 "kind": "prompt",
730 "model": "gpt-4o",
731 "tools": [{
732 "type": "mcp",
733 "server_label": "kb_test",
734 "require_approval": {
735 "never": {"tool_names": ["safe_tool"]},
736 "always": {"tool_names": ["dangerous_tool"]}
737 }
738 }]
739 }
740 }
741 }
742 });
743
744 let flat = flatten_agent_response(&api_response);
745 let ra = &flat["tools"][0]["require_approval"];
746 assert_eq!(ra["never"]["tool_names"][0], "safe_tool");
747 assert_eq!(ra["always"]["tool_names"][0], "dangerous_tool");
748
749 let re_wrapped = wrap_agent_payload(&flat);
751 let def_ra = &re_wrapped["definition"]["tools"][0]["require_approval"];
752 assert_eq!(def_ra["never"]["tool_names"][0], "safe_tool");
753 assert_eq!(def_ra["always"]["tool_names"][0], "dangerous_tool");
754 }
755}