1use std::collections::HashMap;
4use std::sync::Arc;
5
6use futures_util::Stream;
7use reqwest::Method;
8use serde::{Deserialize, Serialize};
9use serde_json::{json, Map, Value};
10
11use crate::client::{CallOptions, Client};
12use crate::common::{string_enum, MessageResponse};
13use crate::error::{Error, Result};
14use crate::pagination::{auto_page, paginate, ListParams, Page, PageFetcher};
15use crate::query::QueryBuilder;
16use crate::resources::rooms::RoomWebhook;
17use crate::resources::{escape, random_uuid, slugify_name};
18
19const DEPLOYMENTS_PATH: &str = "/ai/v1/cloud/deployments";
20const VERSIONS_PATH: &str = "/ai/v1/cloud/versions";
21const SECRETS_PATH: &str = "/ai/v1/cloud/secrets";
22
23#[derive(Debug, Clone, Default, Serialize)]
27#[serde(rename_all = "camelCase")]
28pub struct AgentRoomOptions {
29 #[serde(skip_serializing_if = "Option::is_none")]
31 pub auto_end_session: Option<bool>,
32 #[serde(skip_serializing_if = "Option::is_none")]
34 pub session_timeout_seconds: Option<u32>,
35 #[serde(skip_serializing_if = "Option::is_none")]
37 pub playground: Option<bool>,
38 #[serde(skip_serializing_if = "Option::is_none")]
40 pub vision: Option<bool>,
41 #[serde(skip_serializing_if = "Option::is_none")]
43 pub join_meeting: Option<bool>,
44}
45
46#[derive(Debug, Clone, Default)]
49pub struct DispatchAgentParams {
50 pub agent_id: Option<String>,
52 pub meeting_id: Option<String>,
54 pub room_id: Option<String>,
56 pub room_options: Option<AgentRoomOptions>,
58 pub sip_options: Option<Map<String, Value>>,
60 pub metadata: Option<Map<String, Value>>,
62 pub version_id: Option<String>,
64 pub version_tag: Option<String>,
66 pub wait: Option<Value>,
68}
69
70#[derive(Debug, Clone, Default)]
73pub struct GeneralDispatchAgentParams {
74 pub agent_id: Option<String>,
76 pub meeting_id: Option<String>,
78 pub room_id: Option<String>,
80 pub room_options: Option<AgentRoomOptions>,
82 pub sip_options: Option<Map<String, Value>>,
84 pub metadata: Option<Map<String, Value>>,
86 pub version_id: Option<String>,
88}
89
90impl From<GeneralDispatchAgentParams> for DispatchAgentParams {
91 fn from(params: GeneralDispatchAgentParams) -> Self {
92 Self {
93 agent_id: params.agent_id,
94 meeting_id: params.meeting_id,
95 room_id: params.room_id,
96 room_options: params.room_options,
97 sip_options: params.sip_options,
98 metadata: params.metadata,
99 version_id: params.version_id,
100 version_tag: None,
101 wait: None,
102 }
103 }
104}
105
106#[derive(Debug, Serialize)]
107#[serde(rename_all = "camelCase")]
108struct DispatchWire {
109 #[serde(skip_serializing_if = "Option::is_none")]
110 agent_id: Option<String>,
111 #[serde(skip_serializing_if = "Option::is_none")]
112 room_options: Option<AgentRoomOptions>,
113 #[serde(skip_serializing_if = "Option::is_none")]
114 sip_options: Option<Map<String, Value>>,
115 #[serde(skip_serializing_if = "Option::is_none")]
116 metadata: Option<Map<String, Value>>,
117 #[serde(skip_serializing_if = "Option::is_none")]
118 version_id: Option<String>,
119 #[serde(skip_serializing_if = "Option::is_none")]
120 version_tag: Option<String>,
121 #[serde(skip_serializing_if = "Option::is_none")]
122 wait: Option<Value>,
123 meeting_id: String,
125 #[serde(skip_serializing_if = "Option::is_none")]
126 room_id: Option<String>,
127}
128
129#[derive(Debug, Clone, Deserialize)]
131#[serde(rename_all = "camelCase")]
132pub struct DispatchResult {
133 pub success: Option<bool>,
135 pub job_id: Option<String>,
137 pub status: Option<String>,
139 pub worker_id: Option<String>,
141 pub room_id: Option<String>,
143 pub agent_id: Option<String>,
145 pub cloud: Option<Value>,
147 #[serde(flatten)]
149 pub extra: Map<String, Value>,
150}
151
152#[derive(Debug, Clone, Deserialize)]
154#[serde(rename_all = "camelCase")]
155pub struct AgentInitConfig {
156 pub registry_url: String,
158}
159
160string_enum! {
163 AgentComputeProfile {
165 CPU_SMALL => "cpu-small",
167 CPU_MEDIUM => "cpu-medium",
169 CPU_LARGE => "cpu-large",
171 }
172}
173
174#[derive(Debug, Clone, Deserialize)]
176#[serde(rename_all = "camelCase")]
177pub struct AgentDeployment {
178 pub id: String,
180 pub agent_id: Option<String>,
182 pub name: Option<String>,
184 pub template: Option<String>,
186 pub status: Option<String>,
188 pub env_secret: Option<String>,
190 #[serde(flatten)]
192 pub extra: Map<String, Value>,
193}
194
195#[derive(Debug, Clone, Deserialize)]
197#[serde(rename_all = "camelCase")]
198pub struct AgentDeploymentVersion {
199 pub id: String,
201 pub deployment_id: Option<String>,
203 pub agent_id: Option<String>,
205 pub name: Option<String>,
207 pub image: Option<Value>,
209 pub region: Option<String>,
211 pub min_replica: Option<u32>,
213 pub max_replica: Option<u32>,
215 pub status: Option<String>,
217 pub active: Option<bool>,
219 pub version_tag: Option<String>,
221 #[serde(flatten)]
223 pub extra: Map<String, Value>,
224}
225
226#[derive(Debug, Clone, Deserialize)]
228pub struct AgentLogEntry {
229 pub log: String,
231 pub timestamp: Option<String>,
233 pub metadata: Option<Map<String, Value>>,
235}
236
237#[derive(Debug, Clone, Default, Serialize)]
239#[serde(rename_all = "camelCase")]
240pub struct DeployImage {
241 pub image_url: String,
243 #[serde(rename = "imageCR", skip_serializing_if = "Option::is_none")]
245 pub image_cr: Option<String>,
246 #[serde(skip_serializing_if = "Option::is_none")]
249 pub image_type: Option<String>,
250 #[serde(skip_serializing_if = "Option::is_none")]
252 pub image_pull_secret: Option<String>,
253}
254
255#[derive(Debug, Clone)]
269pub enum AgentImage {
270 Reference(String),
272 Full(DeployImage),
274}
275
276impl From<&str> for AgentImage {
277 fn from(reference: &str) -> Self {
278 AgentImage::Reference(reference.to_string())
279 }
280}
281
282impl From<String> for AgentImage {
283 fn from(reference: String) -> Self {
284 AgentImage::Reference(reference)
285 }
286}
287
288impl From<DeployImage> for AgentImage {
289 fn from(image: DeployImage) -> Self {
290 AgentImage::Full(image)
291 }
292}
293
294#[derive(Debug, Clone, Default)]
296pub struct AgentScaling {
297 pub min: Option<u32>,
299 pub max: Option<u32>,
302}
303
304#[derive(Debug, Clone, Default)]
306pub struct DeployAgentParams {
307 pub name: String,
309 pub image: Option<AgentImage>,
311 pub env: HashMap<String, String>,
314 pub scaling: Option<AgentScaling>,
316 pub profile: Option<AgentComputeProfile>,
318 pub region: Option<String>,
320 pub agent_id: Option<String>,
322 pub version_tag: Option<String>,
324 pub webhook: Option<RoomWebhook>,
326 pub env_secret_name: Option<String>,
328}
329
330#[derive(Debug, Clone)]
332pub struct DeployedAgent {
333 pub deployment: AgentDeployment,
335 pub version: AgentDeploymentVersion,
337 pub secret_id: Option<String>,
339}
340
341fn normalize_deploy_image(image: Option<AgentImage>) -> DeployImage {
343 let mut image = match image {
344 None => DeployImage::default(),
345 Some(AgentImage::Reference(reference)) => DeployImage {
346 image_url: reference,
347 ..Default::default()
348 },
349 Some(AgentImage::Full(image)) => image,
350 };
351
352 if image.image_cr.is_none() {
353 let first_segment = image.image_url.split('/').next().unwrap_or("");
354 image.image_cr = Some(
357 if !first_segment.is_empty() && first_segment.contains(['.', ':']) {
358 first_segment.to_string()
359 } else {
360 "docker.io".to_string()
361 },
362 );
363 }
364
365 if image.image_type.is_none() {
366 image.image_type = Some(
367 if image.image_pull_secret.is_some() {
368 "private"
369 } else {
370 "public"
371 }
372 .to_string(),
373 );
374 }
375 image
376}
377
378#[derive(Debug, Clone, Default)]
380pub struct ListDeploymentsParams {
381 pub page: Option<u32>,
383 pub per_page: Option<u32>,
385 pub cursor: Option<String>,
387 pub agent_id: Option<String>,
389 pub name: Option<String>,
391 pub status: Option<String>,
393 pub deployment_id: Option<String>,
395}
396
397#[derive(Debug, Clone, Default)]
399pub struct ListDeploymentLogsParams {
400 pub page: Option<u32>,
402 pub per_page: Option<u32>,
404 pub cursor: Option<String>,
406 pub agent_id: Option<String>,
408 pub version_id: Option<String>,
410 pub session_id: Option<String>,
412 pub user_id: Option<String>,
414 pub room_id: Option<String>,
416 pub start: Option<String>,
421 pub end: Option<String>,
423 pub sort: Option<String>,
425}
426
427macro_rules! pagination {
428 ($name:ident) => {
429 impl $name {
430 fn pagination(&self) -> ListParams {
431 ListParams {
432 page: self.page,
433 per_page: self.per_page,
434 cursor: self.cursor.clone(),
435 }
436 }
437 }
438 };
439}
440
441pagination!(ListDeploymentsParams);
442pagination!(ListDeploymentLogsParams);
443
444#[derive(Debug, Clone, Copy)]
446pub struct AgentDeploymentResource<'a> {
447 client: &'a Client,
448}
449
450impl<'a> AgentDeploymentResource<'a> {
451 pub async fn list(&self, params: ListDeploymentsParams) -> Result<Page<AgentDeployment>> {
453 paginate(
454 self.fetcher(¶ms),
455 ¶ms.pagination(),
456 "deployments",
457 None,
458 )
459 .await
460 }
461
462 pub fn list_stream(
464 &self,
465 params: ListDeploymentsParams,
466 ) -> impl Stream<Item = Result<AgentDeployment>> + Send {
467 auto_page(
468 self.fetcher(¶ms),
469 params.pagination(),
470 "deployments",
471 None,
472 )
473 }
474
475 pub async fn get(&self, deployment_id: &str) -> Result<AgentDeployment> {
479 let page = self
480 .list(ListDeploymentsParams {
481 deployment_id: Some(deployment_id.to_string()),
482 per_page: Some(1),
483 ..Default::default()
484 })
485 .await?;
486
487 page.data
488 .iter()
489 .find(|deployment| deployment.id == deployment_id)
490 .or_else(|| page.data.first())
491 .cloned()
492 .ok_or_else(|| Error::not_found(format!("agent deployment {deployment_id} not found")))
493 }
494
495 pub async fn get_latest_version(&self, deployment_id: &str) -> Result<AgentDeploymentVersion> {
497 let path = format!("{DEPLOYMENTS_PATH}/{}/latest", escape(deployment_id));
498 self.client
499 .json(Method::GET, &path, CallOptions::new())
500 .await
501 }
502
503 pub async fn logs(
508 &self,
509 deployment_id: &str,
510 params: ListDeploymentLogsParams,
511 ) -> Result<Page<AgentLogEntry>> {
512 let fetcher = self.logs_fetcher(deployment_id, ¶ms);
513 paginate(fetcher, ¶ms.pagination(), "logs", None).await
514 }
515
516 pub fn logs_stream(
518 &self,
519 deployment_id: &str,
520 params: ListDeploymentLogsParams,
521 ) -> impl Stream<Item = Result<AgentLogEntry>> + Send {
522 let fetcher = self.logs_fetcher(deployment_id, ¶ms);
523 auto_page(fetcher, params.pagination(), "logs", None)
524 }
525
526 pub async fn delete(&self, deployment_id: &str, force: bool) -> Result<MessageResponse> {
528 let path = format!("{DEPLOYMENTS_PATH}/{}/delete", escape(deployment_id));
529 let body = json!({ "force": force });
530 self.client
531 .json(Method::POST, &path, CallOptions::json(&body)?)
532 .await
533 }
534
535 pub async fn create(&self, params: DeployAgentParams) -> Result<DeployedAgent> {
543 let image = normalize_deploy_image(params.image);
544
545 let mut secret_id = None;
547 if !params.env.is_empty() {
548 let name = params.env_secret_name.clone().unwrap_or_else(|| {
549 let suffix: String = random_uuid().chars().take(8).collect();
550 format!("{}-env-{suffix}", slugify_name(¶ms.name))
551 });
552 let body = json!({"name": name, "keys": params.env, "type": "NORMAL"});
553
554 #[derive(Deserialize)]
555 struct Secret {
556 id: String,
557 }
558 let secret: Secret = self
559 .client
560 .data(Method::POST, SECRETS_PATH, CallOptions::json(&body)?)
561 .await?;
562 secret_id = Some(secret.id);
563 }
564
565 let mut deploy_body = Map::new();
569 deploy_body.insert("name".into(), json!(params.name));
570 if let Some(agent_id) = params.agent_id.as_deref().filter(|id| !id.is_empty()) {
571 deploy_body.insert("agentId".into(), json!(agent_id));
572 }
573 if let Some(secret_id) = &secret_id {
574 deploy_body.insert("envSecret".into(), json!(secret_id));
575 }
576 let deployment: AgentDeployment = self
577 .client
578 .json(
579 Method::POST,
580 DEPLOYMENTS_PATH,
581 CallOptions::json(&deploy_body)?,
582 )
583 .await?;
584
585 let scaling = params.scaling.unwrap_or_default();
587 let version_body = VersionWire {
588 agent_id: deployment.agent_id.as_deref(),
589 deployment_id: Some(&deployment.id),
590 image,
591 profile: params.profile.unwrap_or(AgentComputeProfile::CPU_SMALL),
592 min_replica: scaling.min,
593 max_replica: scaling.max,
594 region: params.region.as_deref(),
595 version_tag: params.version_tag.as_deref(),
596 webhook: params.webhook.as_ref(),
597 };
598 let version: AgentDeploymentVersion = self
599 .client
600 .json(
601 Method::POST,
602 VERSIONS_PATH,
603 CallOptions::json(&version_body)?,
604 )
605 .await?;
606
607 Ok(DeployedAgent {
608 deployment,
609 version,
610 secret_id,
611 })
612 }
613
614 pub async fn set_version_active(
620 &self,
621 version_id: &str,
622 activate: bool,
623 force: bool,
624 ) -> Result<MessageResponse> {
625 let path = format!("{VERSIONS_PATH}/{}/activate", escape(version_id));
626 let body = json!({"activate": activate, "force": force});
627 self.client
628 .json(Method::PUT, &path, CallOptions::json(&body)?)
629 .await
630 }
631
632 fn fetcher(&self, params: &ListDeploymentsParams) -> PageFetcher {
633 let client = self.client.clone();
634 let params = params.clone();
635 Arc::new(move |page, per_page| {
636 let client = client.clone();
637 let params = params.clone();
638 Box::pin(async move {
639 let query = QueryBuilder::new()
640 .opt("page", page)
641 .opt("perPage", per_page)
642 .opt_str("agentId", params.agent_id.as_deref())
643 .opt_str("name", params.name.as_deref())
644 .opt_str("status", params.status.as_deref())
645 .opt_str("deploymentId", params.deployment_id.as_deref())
646 .into_pairs();
647 client
648 .json::<Value>(
649 Method::GET,
650 DEPLOYMENTS_PATH,
651 CallOptions::new().query(query),
652 )
653 .await
654 })
655 })
656 }
657
658 fn logs_fetcher(&self, deployment_id: &str, params: &ListDeploymentLogsParams) -> PageFetcher {
659 let client = self.client.clone();
660 let params = params.clone();
661 let path = format!("{DEPLOYMENTS_PATH}/{}/console-logs", escape(deployment_id));
662 Arc::new(move |page, per_page| {
663 let client = client.clone();
664 let params = params.clone();
665 let path = path.clone();
666 Box::pin(async move {
667 let query = QueryBuilder::new()
668 .opt("page", page)
669 .opt("perPage", per_page)
670 .opt_str("agentId", params.agent_id.as_deref())
671 .opt_str("versionId", params.version_id.as_deref())
672 .opt_str("sessionId", params.session_id.as_deref())
673 .opt_str("userId", params.user_id.as_deref())
674 .opt_str("roomId", params.room_id.as_deref())
675 .opt_str("start", params.start.as_deref())
676 .opt_str("end", params.end.as_deref())
677 .opt_str("sort", params.sort.as_deref())
678 .into_pairs();
679 client
680 .json::<Value>(Method::GET, &path, CallOptions::new().query(query))
681 .await
682 })
683 })
684 }
685}
686
687#[derive(Debug, Serialize)]
688#[serde(rename_all = "camelCase")]
689struct VersionWire<'a> {
690 #[serde(skip_serializing_if = "Option::is_none")]
691 agent_id: Option<&'a str>,
692 #[serde(skip_serializing_if = "Option::is_none")]
693 deployment_id: Option<&'a str>,
694 image: DeployImage,
695 profile: AgentComputeProfile,
696 #[serde(skip_serializing_if = "Option::is_none")]
697 min_replica: Option<u32>,
698 #[serde(skip_serializing_if = "Option::is_none")]
699 max_replica: Option<u32>,
700 #[serde(skip_serializing_if = "Option::is_none")]
701 region: Option<&'a str>,
702 #[serde(skip_serializing_if = "Option::is_none")]
703 version_tag: Option<&'a str>,
704 #[serde(skip_serializing_if = "Option::is_none")]
705 webhook: Option<&'a RoomWebhook>,
706}
707
708#[derive(Debug, Clone, Copy)]
712pub struct AgentsResource<'a> {
713 client: &'a Client,
714}
715
716impl<'a> AgentsResource<'a> {
717 pub(crate) fn new(client: &'a Client) -> Self {
718 Self { client }
719 }
720
721 pub fn deployment(&self) -> AgentDeploymentResource<'a> {
723 AgentDeploymentResource {
724 client: self.client,
725 }
726 }
727
728 pub async fn dispatch(&self, params: DispatchAgentParams) -> Result<DispatchResult> {
730 self.send("/v2/agent/dispatch", params).await
731 }
732
733 pub async fn connect(&self, params: DispatchAgentParams) -> Result<DispatchResult> {
735 self.send("/v2/agent/connect", params).await
736 }
737
738 pub async fn general_dispatch(
740 &self,
741 params: GeneralDispatchAgentParams,
742 ) -> Result<DispatchResult> {
743 self.send("/v2/agent/general/dispatch", params.into()).await
744 }
745
746 pub async fn init_config(&self) -> Result<AgentInitConfig> {
748 self.client
749 .data(Method::POST, "/v2/agent/init-config", CallOptions::new())
750 .await
751 }
752
753 async fn send(&self, path: &str, params: DispatchAgentParams) -> Result<DispatchResult> {
754 let meeting_id = params
756 .meeting_id
757 .as_deref()
758 .or(params.room_id.as_deref())
759 .filter(|id| !id.is_empty())
760 .ok_or_else(|| Error::validation("agents.dispatch() requires meeting_id (or room_id)"))?
761 .to_string();
762 let room_id = params
763 .room_id
764 .filter(|id| !id.is_empty())
765 .unwrap_or_else(|| meeting_id.clone());
766
767 let body = DispatchWire {
768 agent_id: params.agent_id,
769 room_options: params.room_options,
770 sip_options: params.sip_options,
771 metadata: params.metadata,
772 version_id: params.version_id,
773 version_tag: params.version_tag,
774 wait: params.wait,
775 meeting_id,
776 room_id: Some(room_id),
777 };
778 self.client
779 .data(Method::POST, path, CallOptions::json(&body)?)
780 .await
781 }
782}
783
784#[cfg(test)]
785mod tests {
786 use super::*;
787
788 #[test]
789 fn a_registry_host_is_parsed_out_of_the_image_url() {
790 let image = normalize_deploy_image(Some("registry.videosdk.live/acme/agent:1.4".into()));
791 assert_eq!(image.image_cr.as_deref(), Some("registry.videosdk.live"));
792 assert_eq!(image.image_type.as_deref(), Some("public"));
793
794 let image = normalize_deploy_image(Some("localhost:5000/agent".into()));
796 assert_eq!(image.image_cr.as_deref(), Some("localhost:5000"));
797 }
798
799 #[test]
800 fn a_short_reference_falls_back_to_docker_hub() {
801 for reference in ["acme/agent:1.4", "agent", "library/agent"] {
802 let image = normalize_deploy_image(Some(reference.into()));
803 assert_eq!(image.image_cr.as_deref(), Some("docker.io"), "{reference}");
804 }
805 }
806
807 #[test]
808 fn a_pull_secret_makes_the_image_private() {
809 let image = normalize_deploy_image(Some(
810 DeployImage {
811 image_url: "registry.example.com/agent".into(),
812 image_pull_secret: Some("secret-1".into()),
813 ..Default::default()
814 }
815 .into(),
816 ));
817 assert_eq!(image.image_type.as_deref(), Some("private"));
818 assert_eq!(image.image_url, "registry.example.com/agent");
819 }
820
821 #[test]
822 fn an_explicit_image_object_is_never_overwritten() {
823 let image = normalize_deploy_image(Some(
824 DeployImage {
825 image_url: "registry.example.com/agent".into(),
826 image_cr: Some("custom.registry".into()),
827 image_type: Some("managed".into()),
828 image_pull_secret: None,
829 }
830 .into(),
831 ));
832 assert_eq!(image.image_url, "registry.example.com/agent");
833 assert_eq!(image.image_cr.as_deref(), Some("custom.registry"));
834 assert_eq!(image.image_type.as_deref(), Some("managed"));
835 }
836}