1use reqwest::Client;
4use serde::Deserialize;
5use tracing::debug;
6
7use crate::auth::AzCliAuth;
8use crate::error::ClientError;
9
10const ARM_BASE_URL: &str = "https://management.azure.com";
11
12pub struct ArmClient {
14 http: Client,
15 token: String,
16}
17
18#[derive(Debug, Clone, Deserialize)]
20#[serde(rename_all = "camelCase")]
21pub struct Subscription {
22 pub subscription_id: String,
23 pub display_name: String,
24 pub state: String,
25}
26
27impl std::fmt::Display for Subscription {
28 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29 write!(f, "{} ({})", self.display_name, self.subscription_id)
30 }
31}
32
33#[derive(Debug, Clone, Deserialize)]
35pub struct SearchService {
36 pub name: String,
37 pub location: String,
38 pub sku: SearchServiceSku,
39 #[serde(default)]
40 pub id: String,
41}
42
43#[derive(Debug, Clone, Deserialize)]
44pub struct SearchServiceSku {
45 pub name: String,
46}
47
48impl std::fmt::Display for SearchService {
49 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50 write!(
51 f,
52 "{} ({}, {})",
53 self.name,
54 self.location,
55 self.sku.name.to_uppercase()
56 )
57 }
58}
59
60#[derive(Debug, Clone)]
62pub struct DiscoveredService {
63 pub name: String,
64 pub subscription_id: String,
65 pub location: String,
66}
67
68#[derive(Debug, Clone, Deserialize)]
70pub struct AiServicesAccount {
71 pub name: String,
72 pub location: String,
73 #[serde(default)]
74 pub kind: String,
75 #[serde(default)]
76 pub id: String,
77 #[serde(default)]
78 pub properties: AiServicesAccountProperties,
79}
80
81#[derive(Debug, Clone, Default, Deserialize)]
82pub struct AiServicesAccountProperties {
83 #[serde(default)]
85 pub endpoint: Option<String>,
86}
87
88impl AiServicesAccount {
89 pub fn agents_endpoint(&self) -> String {
95 if let Some(ref endpoint) = self.properties.endpoint {
96 if let Some(subdomain) = extract_subdomain(endpoint) {
97 return format!("https://{}.services.ai.azure.com", subdomain);
98 }
99 }
100 format!("https://{}.services.ai.azure.com", self.name)
101 }
102}
103
104fn extract_subdomain(endpoint: &str) -> Option<&str> {
108 let host = endpoint.strip_prefix("https://")?.split('/').next()?;
109 host.split('.').next()
110}
111
112impl std::fmt::Display for AiServicesAccount {
113 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114 write!(f, "{} ({})", self.name, self.location)
115 }
116}
117
118#[derive(Debug, Clone, Deserialize)]
120pub struct FoundryProject {
121 #[serde(default)]
123 name: String,
124 pub location: String,
125 #[serde(default)]
126 pub id: String,
127 #[serde(default)]
128 pub properties: FoundryProjectProperties,
129}
130
131#[derive(Debug, Clone, Default, Deserialize)]
132#[serde(rename_all = "camelCase")]
133pub struct FoundryProjectProperties {
134 #[serde(default)]
135 pub display_name: String,
136}
137
138impl FoundryProject {
139 pub fn display_name(&self) -> &str {
141 if !self.properties.display_name.is_empty() {
142 &self.properties.display_name
143 } else {
144 self.name.rsplit('/').next().unwrap_or(&self.name)
146 }
147 }
148}
149
150impl std::fmt::Display for FoundryProject {
151 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
152 write!(f, "{} ({})", self.display_name(), self.location)
153 }
154}
155
156#[derive(Debug, Clone, Deserialize)]
158pub struct StorageAccount {
159 pub name: String,
160 pub location: String,
161 #[serde(default)]
162 pub id: String,
163}
164
165impl std::fmt::Display for StorageAccount {
166 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
167 write!(f, "{} ({})", self.name, self.location)
168 }
169}
170
171#[derive(Debug, Clone, Deserialize)]
173struct StorageKey {
174 value: String,
175}
176
177#[derive(Debug, Deserialize)]
179struct StorageKeyList {
180 keys: Vec<StorageKey>,
181}
182
183#[derive(Debug, Clone, Deserialize)]
185pub struct ModelDeployment {
186 pub name: String,
187 #[serde(default)]
188 pub properties: ModelDeploymentProperties,
189 #[serde(default)]
190 pub sku: ModelDeploymentSku,
191}
192
193#[derive(Debug, Clone, Default, Deserialize)]
194pub struct ModelDeploymentProperties {
195 #[serde(default)]
196 pub model: ModelDeploymentModel,
197}
198
199#[derive(Debug, Clone, Default, Deserialize)]
200pub struct ModelDeploymentModel {
201 #[serde(default)]
202 pub name: String,
203 #[serde(default)]
204 pub version: String,
205}
206
207#[derive(Debug, Clone, Default, Deserialize)]
208pub struct ModelDeploymentSku {
209 #[serde(default)]
210 pub name: String,
211 #[serde(default)]
212 pub capacity: u32,
213}
214
215impl std::fmt::Display for ModelDeployment {
216 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
217 write!(
218 f,
219 "{} ({}, {})",
220 self.name, self.properties.model.name, self.sku.name
221 )
222 }
223}
224
225#[derive(Debug, Deserialize)]
227struct ArmListResponse<T> {
228 value: Vec<T>,
229}
230
231impl ArmClient {
232 pub fn new() -> Result<Self, ClientError> {
234 let token = AzCliAuth::get_arm_token()?;
235 let http = Client::builder()
236 .timeout(std::time::Duration::from_secs(30))
237 .build()?;
238
239 Ok(Self { http, token })
240 }
241
242 pub async fn list_subscriptions(&self) -> Result<Vec<Subscription>, ClientError> {
244 let url = format!("{}/subscriptions?api-version=2022-12-01", ARM_BASE_URL);
245 debug!("Listing subscriptions: {}", url);
246
247 let response = self
248 .http
249 .get(&url)
250 .header("Authorization", format!("Bearer {}", self.token))
251 .send()
252 .await?;
253
254 let status = response.status();
255 if !status.is_success() {
256 let body = response.text().await?;
257 return Err(ClientError::from_response(status.as_u16(), &body));
258 }
259
260 let result: ArmListResponse<Subscription> = response.json().await?;
261 Ok(result
263 .value
264 .into_iter()
265 .filter(|s| s.state == "Enabled")
266 .collect())
267 }
268
269 pub async fn list_search_services(
271 &self,
272 subscription_id: &str,
273 ) -> Result<Vec<SearchService>, ClientError> {
274 let url = format!(
275 "{}/subscriptions/{}/providers/Microsoft.Search/searchServices?api-version=2023-11-01",
276 ARM_BASE_URL, subscription_id
277 );
278 debug!("Listing search services: {}", url);
279
280 let response = self
281 .http
282 .get(&url)
283 .header("Authorization", format!("Bearer {}", self.token))
284 .send()
285 .await?;
286
287 let status = response.status();
288 if !status.is_success() {
289 let body = response.text().await?;
290 return Err(ClientError::from_response(status.as_u16(), &body));
291 }
292
293 let result: ArmListResponse<SearchService> = response.json().await?;
294 Ok(result.value)
295 }
296
297 pub async fn find_resource_group(
301 &self,
302 subscription_id: &str,
303 service_name: &str,
304 ) -> Result<String, ClientError> {
305 let services = self.list_search_services(subscription_id).await?;
306
307 for svc in &services {
308 if svc.name.eq_ignore_ascii_case(service_name) {
309 return parse_resource_group(&svc.id).ok_or_else(|| ClientError::Api {
312 status: 0,
313 message: format!("Could not parse resource group from ARM ID: {}", svc.id),
314 });
315 }
316 }
317
318 Err(ClientError::NotFound {
319 kind: "Search service".to_string(),
320 name: service_name.to_string(),
321 })
322 }
323
324 pub async fn list_ai_services_accounts(
326 &self,
327 subscription_id: &str,
328 ) -> Result<Vec<AiServicesAccount>, ClientError> {
329 let url = format!(
330 "{}/subscriptions/{}/providers/Microsoft.CognitiveServices/accounts?api-version=2024-10-01",
331 ARM_BASE_URL, subscription_id
332 );
333 debug!("Listing AI Services accounts: {}", url);
334
335 let response = self
336 .http
337 .get(&url)
338 .header("Authorization", format!("Bearer {}", self.token))
339 .send()
340 .await?;
341
342 let status = response.status();
343 if !status.is_success() {
344 let body = response.text().await?;
345 return Err(ClientError::from_response(status.as_u16(), &body));
346 }
347
348 let result: ArmListResponse<AiServicesAccount> = response.json().await?;
349 Ok(result
350 .value
351 .into_iter()
352 .filter(|a| a.kind.eq_ignore_ascii_case("AIServices"))
353 .collect())
354 }
355
356 pub async fn list_foundry_projects(
364 &self,
365 account: &AiServicesAccount,
366 subscription_id: &str,
367 ) -> Result<Vec<FoundryProject>, ClientError> {
368 let resource_group = parse_resource_group(&account.id).ok_or_else(|| ClientError::Api {
369 status: 0,
370 message: format!("Could not parse resource group from ARM ID: {}", account.id),
371 })?;
372
373 let url = format!(
374 "{}/subscriptions/{}/resourceGroups/{}/providers/Microsoft.CognitiveServices/accounts/{}/projects?api-version=2025-06-01",
375 ARM_BASE_URL, subscription_id, resource_group, account.name
376 );
377 debug!("Listing Foundry projects: {}", url);
378
379 let response = self
380 .http
381 .get(&url)
382 .header("Authorization", format!("Bearer {}", self.token))
383 .send()
384 .await?;
385
386 let status = response.status();
387 if !status.is_success() {
388 let body = response.text().await?;
389 return Err(ClientError::from_response(status.as_u16(), &body));
390 }
391
392 let result: ArmListResponse<FoundryProject> = response.json().await?;
393 Ok(result.value)
394 }
395
396 pub async fn list_storage_accounts(
398 &self,
399 subscription_id: &str,
400 resource_group: &str,
401 ) -> Result<Vec<StorageAccount>, ClientError> {
402 let url = format!(
403 "{}/subscriptions/{}/resourceGroups/{}/providers/Microsoft.Storage/storageAccounts?api-version=2023-05-01",
404 ARM_BASE_URL, subscription_id, resource_group
405 );
406 debug!("Listing storage accounts: {}", url);
407
408 let response = self
409 .http
410 .get(&url)
411 .header("Authorization", format!("Bearer {}", self.token))
412 .send()
413 .await?;
414
415 let status = response.status();
416 if !status.is_success() {
417 let body = response.text().await?;
418 return Err(ClientError::from_response(status.as_u16(), &body));
419 }
420
421 let result: ArmListResponse<StorageAccount> = response.json().await?;
422 Ok(result.value)
423 }
424
425 pub async fn get_storage_account_key(
427 &self,
428 subscription_id: &str,
429 resource_group: &str,
430 account_name: &str,
431 ) -> Result<String, ClientError> {
432 let url = format!(
433 "{}/subscriptions/{}/resourceGroups/{}/providers/Microsoft.Storage/storageAccounts/{}/listKeys?api-version=2023-05-01",
434 ARM_BASE_URL, subscription_id, resource_group, account_name
435 );
436 debug!("Getting storage account keys: {}", url);
437
438 let response = self
439 .http
440 .post(&url)
441 .header("Authorization", format!("Bearer {}", self.token))
442 .header("Content-Length", "0")
443 .send()
444 .await?;
445
446 let status = response.status();
447 if !status.is_success() {
448 let body = response.text().await?;
449 return Err(ClientError::from_response(status.as_u16(), &body));
450 }
451
452 let key_list: StorageKeyList = response.json().await?;
453 key_list
454 .keys
455 .into_iter()
456 .next()
457 .map(|k| k.value)
458 .ok_or_else(|| ClientError::Api {
459 status: 0,
460 message: "No keys found for storage account".to_string(),
461 })
462 }
463
464 pub async fn list_model_deployments(
466 &self,
467 account: &AiServicesAccount,
468 subscription_id: &str,
469 ) -> Result<Vec<ModelDeployment>, ClientError> {
470 let resource_group = parse_resource_group(&account.id).ok_or_else(|| ClientError::Api {
471 status: 0,
472 message: format!("Could not parse resource group from ARM ID: {}", account.id),
473 })?;
474
475 let url = format!(
476 "{}/subscriptions/{}/resourceGroups/{}/providers/Microsoft.CognitiveServices/accounts/{}/deployments?api-version=2024-10-01",
477 ARM_BASE_URL, subscription_id, resource_group, account.name
478 );
479 debug!("Listing model deployments: {}", url);
480
481 let response = self
482 .http
483 .get(&url)
484 .header("Authorization", format!("Bearer {}", self.token))
485 .send()
486 .await?;
487
488 let status = response.status();
489 if !status.is_success() {
490 let body = response.text().await?;
491 return Err(ClientError::from_response(status.as_u16(), &body));
492 }
493
494 let result: ArmListResponse<ModelDeployment> = response.json().await?;
495 Ok(result.value)
496 }
497
498 pub async fn create_model_deployment(
500 &self,
501 account: &AiServicesAccount,
502 subscription_id: &str,
503 deployment_name: &str,
504 model_name: &str,
505 model_version: &str,
506 ) -> Result<(), ClientError> {
507 let resource_group = parse_resource_group(&account.id).ok_or_else(|| ClientError::Api {
508 status: 0,
509 message: format!("Could not parse resource group from ARM ID: {}", account.id),
510 })?;
511
512 let url = format!(
513 "{}/subscriptions/{}/resourceGroups/{}/providers/Microsoft.CognitiveServices/accounts/{}/deployments/{}?api-version=2024-10-01",
514 ARM_BASE_URL, subscription_id, resource_group, account.name, deployment_name
515 );
516 debug!("Creating model deployment: {}", url);
517
518 let body = serde_json::json!({
519 "sku": {
520 "name": "GlobalStandard",
521 "capacity": 1
522 },
523 "properties": {
524 "model": {
525 "format": "OpenAI",
526 "name": model_name,
527 "version": model_version
528 }
529 }
530 });
531
532 let response = self
533 .http
534 .put(&url)
535 .header("Authorization", format!("Bearer {}", self.token))
536 .json(&body)
537 .send()
538 .await?;
539
540 let status = response.status();
541 if !status.is_success() {
542 let body = response.text().await?;
543 return Err(ClientError::from_response(status.as_u16(), &body));
544 }
545
546 Ok(())
547 }
548
549 pub async fn get_storage_connection_string(
551 &self,
552 subscription_id: &str,
553 resource_group: &str,
554 account_name: &str,
555 ) -> Result<String, ClientError> {
556 let key = self
557 .get_storage_account_key(subscription_id, resource_group, account_name)
558 .await?;
559
560 Ok(format!(
561 "DefaultEndpointsProtocol=https;AccountName={};AccountKey={};EndpointSuffix=core.windows.net",
562 account_name, key
563 ))
564 }
565}
566
567fn parse_resource_group(arm_id: &str) -> Option<String> {
571 let parts: Vec<&str> = arm_id.split('/').collect();
572 for (i, part) in parts.iter().enumerate() {
573 if part.eq_ignore_ascii_case("resourceGroups")
574 || part.eq_ignore_ascii_case("resourcegroups")
575 {
576 return parts.get(i + 1).map(|s| s.to_string());
577 }
578 }
579 None
580}
581
582#[cfg(test)]
583mod tests {
584 use super::*;
585
586 #[test]
587 fn test_parse_resource_group() {
588 let id = "/subscriptions/abc-123/resourceGroups/my-rg/providers/Microsoft.Search/searchServices/my-svc";
589 assert_eq!(parse_resource_group(id), Some("my-rg".to_string()));
590 }
591
592 #[test]
593 fn test_parse_resource_group_case_insensitive() {
594 let id = "/subscriptions/abc/resourcegroups/MyRG/providers/Something";
595 assert_eq!(parse_resource_group(id), Some("MyRG".to_string()));
596 }
597
598 #[test]
599 fn test_parse_resource_group_missing() {
600 let id = "/subscriptions/abc/providers/Something";
601 assert_eq!(parse_resource_group(id), None);
602 }
603
604 #[test]
605 fn test_ai_services_account_display() {
606 let account = AiServicesAccount {
607 name: "my-ai-service".to_string(),
608 location: "eastus".to_string(),
609 kind: "AIServices".to_string(),
610 id: String::new(),
611 properties: AiServicesAccountProperties::default(),
612 };
613 assert_eq!(format!("{}", account), "my-ai-service (eastus)");
614 }
615
616 #[test]
617 fn test_agents_endpoint_from_arm_endpoint() {
618 let account = AiServicesAccount {
619 name: "irma-prod-foundry".to_string(),
620 location: "swedencentral".to_string(),
621 kind: "AIServices".to_string(),
622 id: String::new(),
623 properties: AiServicesAccountProperties {
624 endpoint: Some("https://custom-subdomain.cognitiveservices.azure.com/".to_string()),
625 },
626 };
627 assert_eq!(
628 account.agents_endpoint(),
629 "https://custom-subdomain.services.ai.azure.com"
630 );
631 }
632
633 #[test]
634 fn test_agents_endpoint_fallback_to_name() {
635 let account = AiServicesAccount {
636 name: "irma-prod-foundry".to_string(),
637 location: "swedencentral".to_string(),
638 kind: "AIServices".to_string(),
639 id: String::new(),
640 properties: AiServicesAccountProperties::default(),
641 };
642 assert_eq!(
643 account.agents_endpoint(),
644 "https://irma-prod-foundry.services.ai.azure.com"
645 );
646 }
647
648 #[test]
649 fn test_extract_subdomain() {
650 assert_eq!(
651 extract_subdomain("https://my-svc.cognitiveservices.azure.com/"),
652 Some("my-svc")
653 );
654 assert_eq!(
655 extract_subdomain("https://custom.services.ai.azure.com"),
656 Some("custom")
657 );
658 assert_eq!(extract_subdomain("not-a-url"), None);
659 }
660
661 #[test]
662 fn test_foundry_project_display_with_display_name() {
663 let project = FoundryProject {
664 name: "my-account/my-project".to_string(),
665 location: "westus2".to_string(),
666 id: String::new(),
667 properties: FoundryProjectProperties {
668 display_name: "my-project".to_string(),
669 },
670 };
671 assert_eq!(format!("{}", project), "my-project (westus2)");
672 assert_eq!(project.display_name(), "my-project");
673 }
674
675 #[test]
676 fn test_model_deployment_display() {
677 let deployment = ModelDeployment {
678 name: "gpt-4o-mini".to_string(),
679 properties: ModelDeploymentProperties {
680 model: ModelDeploymentModel {
681 name: "gpt-4o-mini".to_string(),
682 version: "2024-07-18".to_string(),
683 },
684 },
685 sku: ModelDeploymentSku {
686 name: "GlobalStandard".to_string(),
687 capacity: 1,
688 },
689 };
690 assert_eq!(
691 format!("{}", deployment),
692 "gpt-4o-mini (gpt-4o-mini, GlobalStandard)"
693 );
694 }
695
696 #[test]
697 fn test_foundry_project_display_name_fallback() {
698 let project = FoundryProject {
699 name: "my-account/proj-default".to_string(),
700 location: "swedencentral".to_string(),
701 id: String::new(),
702 properties: FoundryProjectProperties::default(),
703 };
704 assert_eq!(project.display_name(), "proj-default");
705 assert_eq!(format!("{}", project), "proj-default (swedencentral)");
706 }
707}