pub mod api;
pub mod config;
pub use config::{DIDConfig, DIDLlmConfig};
use std::collections::HashMap;
use async_trait::async_trait;
use secrecy::ExposeSecret;
use tokio::sync::RwLock;
use super::types::{AvatarSessionInfo, VideoStreamInfo};
use super::{AvatarProvider, AvatarResult};
use crate::error::RealtimeError;
#[derive(Debug)]
struct DIDSession {
#[allow(dead_code)]
chat_id: String,
}
pub struct DIDProvider {
config: DIDConfig,
http_client: reqwest::Client,
sessions: RwLock<HashMap<String, DIDSession>>,
}
impl std::fmt::Debug for DIDProvider {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DIDProvider")
.field("config", &self.config)
.field("sessions_count", &"<locked>")
.finish()
}
}
impl DIDProvider {
pub fn new(config: DIDConfig) -> Self {
assert!(
config.api_base_url.starts_with("https://"),
"d-id: api_base_url must use https:// for secure transport, got: {}",
config.api_base_url
);
Self { config, http_client: reqwest::Client::new(), sessions: RwLock::new(HashMap::new()) }
}
fn secure_url(&self, path: &str) -> AvatarResult<String> {
if !self.config.api_base_url.starts_with("https://") {
return Err(RealtimeError::provider(
"d-id: api_base_url must use https:// for secure transport",
));
}
Ok(format!("{}{path}", self.config.api_base_url))
}
}
#[async_trait]
impl AvatarProvider for DIDProvider {
fn name(&self) -> &str {
"d-id"
}
async fn start_session(
&self,
avatar_config: &super::config::AvatarConfig,
) -> AvatarResult<AvatarSessionInfo> {
if self.config.agent_id.is_empty() {
return Err(RealtimeError::config("d-id: agent_id must not be empty"));
}
if avatar_config.source_url.is_empty() {
return Err(RealtimeError::config("d-id: avatar source_url must not be empty"));
}
let request_body = api::CreateSessionRequest {
source_url: avatar_config.source_url.clone(),
llm: self.config.llm_config.clone(),
knowledge_id: self.config.knowledge_id.clone(),
};
let url = self.secure_url(&format!("/agents/{}/chat", self.config.agent_id))?;
tracing::info!(url = %url, "d-id: creating agent chat session");
let response = self
.http_client
.post(&url)
.header("Authorization", format!("Basic {}", self.config.api_key.expose_secret()))
.header("Content-Type", "application/json")
.json(&request_body)
.send()
.await
.map_err(|e| RealtimeError::provider(format!("d-id: REST request failed: {e}")))?;
let status = response.status();
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
return Err(RealtimeError::AuthError(format!(
"d-id: authentication failed (HTTP {status})"
)));
}
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
return Err(RealtimeError::provider(format!(
"d-id: session creation failed (HTTP {status}): {body}"
)));
}
let session_response: api::CreateSessionResponse = response
.json()
.await
.map_err(|e| RealtimeError::provider(format!("d-id: failed to parse response: {e}")))?;
let session_id = session_response.session_id.clone();
let chat_id = session_response.id.clone();
tracing::info!(
session_id = %session_id,
chat_id = %chat_id,
ice_servers = session_response.ice_servers.len(),
"d-id: session created with SDP offer"
);
let session_info = AvatarSessionInfo {
session_id: session_id.clone(),
video_stream: VideoStreamInfo::WebRTC {
sdp_answer: session_response.offer,
ice_servers: session_response.ice_servers,
},
provider: "d-id".to_string(),
};
let did_session = DIDSession { chat_id };
self.sessions.write().await.insert(session_id, did_session);
Ok(session_info)
}
async fn send_audio(&self, session_id: &str, _audio: &[u8]) -> AvatarResult<()> {
let sessions = self.sessions.read().await;
if !sessions.contains_key(session_id) {
return Err(RealtimeError::provider(format!(
"d-id: no active session with id '{session_id}'"
)));
}
tracing::debug!(
session_id = %session_id,
"d-id: send_audio is a no-op (D-ID renders from its own TTS)"
);
Ok(())
}
async fn keep_alive(&self, session_id: &str) -> AvatarResult<()> {
let sessions = self.sessions.read().await;
if !sessions.contains_key(session_id) {
return Err(RealtimeError::provider(format!(
"d-id: no active session with id '{session_id}'"
)));
}
tracing::debug!(
session_id = %session_id,
"d-id: keep_alive is a no-op (D-ID manages session timeout internally)"
);
Ok(())
}
async fn stop_session(&self, session_id: &str) -> AvatarResult<()> {
let session = self.sessions.write().await.remove(session_id);
let Some(_session) = session else {
tracing::debug!(session_id = %session_id, "d-id: session already stopped (no-op)");
return Ok(());
};
let url =
self.secure_url(&format!("/agents/{}/chat/{session_id}", self.config.agent_id))?;
tracing::info!(session_id = %session_id, "d-id: stopping session");
let result = self
.http_client
.delete(&url)
.header("Authorization", format!("Basic {}", self.config.api_key.expose_secret()))
.send()
.await;
match result {
Ok(response) if !response.status().is_success() => {
let status = response.status();
let body = response.text().await.unwrap_or_default();
tracing::warn!(
session_id = %session_id,
status = %status,
body = %body,
"d-id: stop session API returned non-success status"
);
}
Err(e) => {
tracing::warn!(
session_id = %session_id,
error = %e,
"d-id: stop session API request failed"
);
}
Ok(_) => {
tracing::info!(session_id = %session_id, "d-id: session stopped via API");
}
}
Ok(())
}
async fn is_active(&self, session_id: &str) -> bool {
self.sessions.read().await.contains_key(session_id)
}
}