1mod error;
5
6use modelexpress_common::providers::ModelProviderTrait;
7use modelexpress_common::{
8 Result as CommonResult, Utils,
9 cache::{CacheConfig, CacheStats},
10 client_config::ClientConfig as Config,
11 constants, download,
12 grpc::{
13 api::{ApiRequest, api_service_client::ApiServiceClient},
14 health::{HealthRequest, health_service_client::HealthServiceClient},
15 model::{ModelDownloadRequest, model_service_client::ModelServiceClient},
16 },
17 models::{ModelStatus, Status},
18 providers::huggingface::HuggingFaceProvider,
19};
20use std::collections::HashMap;
21use std::env;
22use std::path::PathBuf;
23use std::time::Duration;
24use tonic::transport::Channel;
25use tracing::debug;
26use tracing::info;
27#[cfg(test)]
28use tracing::warn;
29use uuid::Uuid;
30
31pub use crate::error::ClientError;
33pub use modelexpress_common::client_config::{ClientArgs, ClientConfig};
34pub use modelexpress_common::models::ModelProvider;
35
36pub struct Client {
38 health_client: HealthServiceClient<Channel>,
39 api_client: ApiServiceClient<Channel>,
40 model_client: ModelServiceClient<Channel>,
41 cache_config: Option<CacheConfig>,
42}
43
44impl Client {
45 pub async fn new(config: Config) -> CommonResult<Self> {
47 let endpoint = &config.connection.endpoint;
48 let timeout = config
49 .connection
50 .timeout_secs
51 .unwrap_or(constants::DEFAULT_TIMEOUT_SECS);
52
53 let channel = tonic::transport::Endpoint::new(endpoint.clone())
54 .map(|endpoint| endpoint.timeout(Duration::from_secs(timeout)))?
55 .connect()
56 .await?;
57
58 let health_client = HealthServiceClient::new(channel.clone());
59 let api_client = ApiServiceClient::new(channel.clone());
60 let model_client = ModelServiceClient::new(channel);
61
62 let cache_config = Some(config.cache.clone());
64
65 Ok(Self {
66 health_client,
67 api_client,
68 model_client,
69 cache_config,
70 })
71 }
72
73 pub async fn new_with_cache(config: Config, cache_config: CacheConfig) -> CommonResult<Self> {
75 let mut client = Self::new(config).await?;
76 client.cache_config = Some(cache_config);
77 Ok(client)
78 }
79
80 pub fn get_cache_config(&self) -> Option<&CacheConfig> {
82 self.cache_config.as_ref()
83 }
84
85 pub fn set_cache_config(&mut self, cache_config: CacheConfig) {
87 self.cache_config = Some(cache_config);
88 }
89
90 pub fn list_cached_models(&self) -> CommonResult<CacheStats> {
92 let cache_config = self.cache_config.as_ref().ok_or_else(|| {
93 modelexpress_common::Error::Server("Cache not configured".to_string())
94 })?;
95
96 cache_config.get_cache_stats().map_err(|e| {
97 modelexpress_common::Error::Server(format!("Failed to get cache stats: {e}")).into()
98 })
99 }
100
101 pub fn clear_cached_model(&self, model_name: &str) -> CommonResult<()> {
103 let cache_config = self.cache_config.as_ref().ok_or_else(|| {
104 modelexpress_common::Error::Server("Cache not configured".to_string())
105 })?;
106
107 cache_config.clear_model(model_name).map_err(|e| {
108 modelexpress_common::Error::Server(format!("Failed to clear model: {e}")).into()
109 })
110 }
111
112 pub fn clear_all_cached_models(&self) -> CommonResult<()> {
114 let cache_config = self.cache_config.as_ref().ok_or_else(|| {
115 modelexpress_common::Error::Server("Cache not configured".to_string())
116 })?;
117
118 cache_config.clear_all().map_err(|e| {
119 modelexpress_common::Error::Server(format!("Failed to clear cache: {e}")).into()
120 })
121 }
122
123 fn get_model_express_cache_dir() -> PathBuf {
125 if let Ok(cache_path) = env::var("HF_HUB_CACHE") {
126 return PathBuf::from(cache_path);
127 }
128
129 if let Ok(cache_path) = env::var("MODEL_EXPRESS_CACHE_PATH") {
130 return PathBuf::from(cache_path);
131 }
132 let home = Utils::get_home_dir().unwrap_or_else(|_| ".".to_string());
133
134 PathBuf::from(home).join(".cache/huggingface/hub")
135 }
136
137 pub async fn get_model_path(&self, model_name: &str) -> anyhow::Result<PathBuf> {
138 let cache_dir = Client::get_model_express_cache_dir();
139 let model_path = HuggingFaceProvider
140 .get_model_path(model_name, cache_dir)
141 .await
142 .map_err(|e| anyhow::anyhow!(format!("Failed to get model path: {e}")))?;
143
144 debug!("Found model path at {:?}", model_path);
145 Ok(model_path)
146 }
147
148 pub async fn preload_model_to_cache(
150 &mut self,
151 model_name: &str,
152 provider: ModelProvider,
153 ignore_weights: bool,
154 ) -> CommonResult<()> {
155 info!("Pre-loading model {} to cache", model_name);
156
157 match self
159 .request_model_with_provider(model_name, provider, ignore_weights)
160 .await
161 {
162 Ok(()) => {
163 info!("Model {} pre-loaded successfully via server", model_name);
164 Ok(())
165 }
166 Err(e) => {
167 info!(
169 "Server unavailable, pre-loading model {} directly. Error: {}",
170 model_name, e
171 );
172 Self::download_model_directly(model_name, provider, ignore_weights).await
173 }
174 }
175 }
176
177 pub async fn health_check(&mut self) -> CommonResult<Status> {
179 let request = tonic::Request::new(HealthRequest {});
180
181 let response = self.health_client.get_health(request).await?;
182 let health_response = response.into_inner();
183
184 Ok(health_response.into())
185 }
186
187 pub async fn send_request<T: serde::de::DeserializeOwned>(
189 &mut self,
190 action: impl Into<String>,
191 payload: Option<HashMap<String, serde_json::Value>>,
192 ) -> CommonResult<T> {
193 let request_id = Uuid::new_v4().to_string();
194
195 let payload_bytes = if let Some(payload) = payload {
196 Some(
197 serde_json::to_vec(&payload)
198 .map_err(|e| modelexpress_common::Error::Serialization(e.to_string()))?,
199 )
200 } else {
201 None
202 };
203
204 let grpc_request = tonic::Request::new(ApiRequest {
205 id: request_id,
206 action: action.into(),
207 payload: payload_bytes,
208 });
209
210 let response = self.api_client.send_request(grpc_request).await?;
211 let api_response = response.into_inner();
212
213 if !api_response.success {
214 return Err(modelexpress_common::Error::Server(
215 api_response
216 .error
217 .unwrap_or_else(|| "Unknown server error".to_string()),
218 )
219 .into());
220 }
221
222 let data_bytes = api_response.data.ok_or_else(|| {
223 modelexpress_common::Error::Server("Server returned success but no data".to_string())
224 })?;
225
226 let data: T = serde_json::from_slice(&data_bytes)
227 .map_err(|e| modelexpress_common::Error::Serialization(e.to_string()))?;
228
229 Ok(data)
230 }
231
232 pub async fn request_model_with_provider_and_fallback(
236 &mut self,
237 model_name: impl Into<String>,
238 provider: ModelProvider,
239 ignore_weights: bool,
240 ) -> CommonResult<()> {
241 let model_name = model_name.into();
242
243 match self
245 .request_model_with_provider(&model_name, provider, ignore_weights)
246 .await
247 {
248 Ok(()) => {
249 info!("Model {} downloaded successfully via server", model_name);
250 Ok(())
251 }
252 Err(e) => {
253 if let modelexpress_common::Error::Transport(_) = *e {
255 info!(
256 "Server unavailable, falling back to direct download for model: {}",
257 model_name
258 );
259
260 let cache_dir = CacheConfig::discover().ok().map(|config| config.local_path);
262 match download::download_model(&model_name, provider, cache_dir, ignore_weights).await {
263 Ok(_) => {
264 info!(
265 "Model {} downloaded successfully via direct download",
266 model_name
267 );
268 Ok(())
269 }
270 Err(download_err) => Err(modelexpress_common::Error::Server(format!(
271 "Both server and direct download failed. Server error: {e}. Download error: {download_err}"
272 )).into()),
273 }
274 } else {
275 Err(e)
277 }
278 }
279 }
280 }
281
282 pub async fn request_model_with_provider(
285 &mut self,
286 model_name: impl Into<String>,
287 provider: ModelProvider,
288 ignore_weights: bool,
289 ) -> CommonResult<()> {
290 let model_name = model_name.into();
291 info!(
292 "Requesting model: {} from provider: {:?}",
293 model_name, provider
294 );
295
296 let grpc_request = tonic::Request::new(ModelDownloadRequest {
297 model_name: model_name.clone(),
298 provider: modelexpress_common::grpc::model::ModelProvider::from(provider) as i32,
299 ignore_weights,
300 });
301
302 let mut stream = self
303 .model_client
304 .ensure_model_downloaded(grpc_request)
305 .await?
306 .into_inner();
307
308 while let Some(update_result) = stream.message().await? {
310 let status: ModelStatus =
311 modelexpress_common::grpc::model::ModelStatus::try_from(update_result.status)
312 .unwrap_or(modelexpress_common::grpc::model::ModelStatus::Error)
313 .into();
314
315 if let Some(message) = &update_result.message {
317 info!("Model {}: {}", model_name, message);
318 } else {
319 info!("Model {} status: {:?}", model_name, status);
320 }
321
322 match status {
324 ModelStatus::DOWNLOADED => {
325 info!("Model {} is now available", model_name);
326 return Ok(());
327 }
328 ModelStatus::ERROR => {
329 let error_message = update_result
330 .message
331 .unwrap_or_else(|| "Unknown error occurred".to_string());
332 return Err(modelexpress_common::Error::Server(format!(
333 "Model download failed: {error_message}"
334 ))
335 .into());
336 }
337 ModelStatus::DOWNLOADING => {
338 continue;
340 }
341 }
342 }
343
344 Err(modelexpress_common::Error::Server(
346 "Model download stream ended unexpectedly".to_string(),
347 )
348 .into())
349 }
350
351 pub async fn request_model(
354 &mut self,
355 model_name: impl Into<String>,
356 ignore_weights: bool,
357 ) -> CommonResult<()> {
358 self.request_model_with_provider_and_fallback(
359 model_name,
360 ModelProvider::default(),
361 ignore_weights,
362 )
363 .await
364 }
365
366 pub async fn request_model_server_only(
369 &mut self,
370 model_name: impl Into<String>,
371 ignore_weights: bool,
372 ) -> CommonResult<()> {
373 self.request_model_with_provider(model_name, ModelProvider::default(), ignore_weights)
374 .await
375 }
376
377 pub async fn request_model_with_smart_fallback(
380 model_name: impl Into<String>,
381 provider: ModelProvider,
382 config: Config,
383 ignore_weights: bool,
384 ) -> CommonResult<()> {
385 let model_name = model_name.into();
386
387 match Client::new(config.clone()).await {
389 Ok(mut client) => {
390 info!("Server connection established, downloading via server...");
391 client
392 .request_model_with_provider_and_fallback(&model_name, provider, ignore_weights)
393 .await
394 }
395 Err(e) => {
396 info!("Cannot connect to server ({}), downloading directly...", e);
398 Client::download_model_directly(&model_name, provider, ignore_weights).await
399 }
400 }
401 }
402
403 pub async fn download_model_directly(
406 model_name: impl Into<String>,
407 provider: ModelProvider,
408 ignore_weights: bool,
409 ) -> CommonResult<()> {
410 let model_name = model_name.into();
411 info!(
412 "Downloading model {} directly using provider: {:?}",
413 model_name, provider
414 );
415
416 let cache_dir = CacheConfig::discover().ok().map(|config| config.local_path);
418
419 download::download_model(&model_name, provider, cache_dir, ignore_weights)
420 .await
421 .map_err(|e| {
422 modelexpress_common::Error::Server(format!("Direct download failed: {e}"))
423 })?;
424
425 info!("Model {} downloaded successfully", model_name);
426 Ok(())
427 }
428}
429
430#[cfg(test)]
431#[allow(clippy::expect_used)]
432mod tests {
433 use super::*;
434 use modelexpress_common::cache::CacheConfig;
435 use std::collections::HashMap;
436
437 fn create_test_client_config() -> ClientConfig {
438 ClientConfig::for_testing("http://test-endpoint:1234")
439 }
440
441 #[test]
442 fn test_client_config_creation() {
443 let config = create_test_client_config();
444 assert_eq!(config.connection.endpoint, "http://test-endpoint:1234");
445 }
446
447 #[test]
448 fn test_client_config_default_timeout() {
449 let config = create_test_client_config();
450 assert!(config.connection.timeout_secs.is_some());
451 }
452
453 #[test]
454 fn test_client_config_with_timeout() {
455 let mut config = create_test_client_config();
456 config.connection.timeout_secs = Some(60);
457 assert_eq!(config.connection.timeout_secs, Some(60));
458 }
459
460 #[test]
461 fn test_endpoint_override_priority() {
462 let config = ClientConfig::for_testing("http://command-line-endpoint:5678");
464
465 assert_eq!(
467 config.connection.endpoint,
468 "http://command-line-endpoint:5678"
469 );
470 }
471
472 #[test]
473 fn test_cache_config_endpoint_override() {
474 let mut cache_config = CacheConfig {
476 local_path: std::path::PathBuf::from("/test/path"),
477 server_endpoint: "http://original-endpoint:1234".to_string(),
478 timeout_secs: None,
479 };
480
481 cache_config.server_endpoint = "http://override-endpoint:5678".to_string();
483
484 assert_eq!(
485 cache_config.server_endpoint,
486 "http://override-endpoint:5678"
487 );
488 }
489
490 #[tokio::test]
494 async fn test_client_new_with_invalid_endpoint() {
495 let config = ClientConfig::for_testing("invalid-endpoint");
496
497 let result = Client::new(config).await;
498 assert!(result.is_err());
499 }
500
501 #[tokio::test]
502 async fn test_download_model_directly_invalid_model() {
503 let result = Client::download_model_directly(
506 "definitely-not-a-real-model-name-12345",
507 ModelProvider::HuggingFace,
508 false,
509 )
510 .await;
511
512 assert!(result.is_err());
514 }
515
516 #[test]
517 fn test_model_provider_enum() {
518 let provider = ModelProvider::HuggingFace;
519 assert_eq!(provider, ModelProvider::HuggingFace);
520
521 let default_provider = ModelProvider::default();
522 assert_eq!(default_provider, ModelProvider::HuggingFace);
523 }
524
525 #[test]
526 fn test_serialization_payload_creation() {
527 let mut payload = HashMap::new();
528 payload.insert(
529 "key1".to_string(),
530 serde_json::Value::String("value1".to_string()),
531 );
532 payload.insert(
533 "key2".to_string(),
534 serde_json::Value::Number(serde_json::Number::from(42)),
535 );
536
537 let payload_bytes =
538 serde_json::to_vec(&payload).expect("Serialization should not fail in test");
539 let deserialized: HashMap<String, serde_json::Value> =
540 serde_json::from_slice(&payload_bytes)
541 .expect("Deserialization should not fail in test");
542
543 assert_eq!(payload, deserialized);
544 }
545
546 #[tokio::test]
547 async fn test_request_model_with_smart_fallback_no_server() {
548 let config = Config::for_testing("http://127.0.0.1:99999"); let result = Client::request_model_with_smart_fallback(
552 "definitely-not-a-real-model-name-12345",
553 ModelProvider::HuggingFace,
554 config,
555 false,
556 )
557 .await;
558
559 assert!(result.is_err());
561 let error_msg = result.expect_err("Result should be an error").to_string();
563 assert!(
564 error_msg.contains("Direct download failed")
565 || error_msg.contains("Cannot connect")
566 || error_msg.contains("gRPC error")
567 || error_msg.contains("h2 protocol error")
568 || error_msg.contains("connection")
569 );
570 }
571}
572
573#[cfg(test)]
574#[allow(clippy::expect_used)]
575mod integration_tests {
576 use super::*;
577 use modelexpress_common::constants;
578 use std::time::Duration;
579 use tokio::time::timeout;
580
581 async fn wait_for_server_startup() {
585 tokio::time::sleep(Duration::from_millis(100)).await;
587 }
588
589 async fn try_create_client() -> Option<Client> {
590 let config =
591 Config::for_testing(format!("http://127.0.0.1:{}", constants::DEFAULT_GRPC_PORT));
592
593 match timeout(Duration::from_secs(2), Client::new(config)).await {
594 Ok(Ok(client)) => Some(client),
595 _ => None,
596 }
597 }
598
599 #[tokio::test]
600 #[ignore = "Ignore by default since it requires a running server"]
601 async fn test_integration_health_check() {
602 wait_for_server_startup().await;
603
604 if let Some(mut client) = try_create_client().await {
605 let result = client.health_check().await;
606 assert!(result.is_ok());
607
608 let status = result.expect("Health check should succeed");
609 assert!(!status.version.is_empty());
610 assert_eq!(status.status, "ok");
611 let _uptime = status.uptime;
613 } else {
614 info!("Skipping integration test - server not available");
615 }
616 }
617
618 #[tokio::test]
619 #[ignore = "Ignore by default since it requires a running server"]
620 async fn test_integration_ping_request() {
621 wait_for_server_startup().await;
622
623 if let Some(mut client) = try_create_client().await {
624 let result: Result<serde_json::Value, _> = client.send_request("ping", None).await;
625 assert!(result.is_ok());
626
627 let response = result.expect("Ping request should succeed");
628 assert_eq!(response["message"], "pong");
629 } else {
630 info!("Skipping integration test - server not available");
631 }
632 }
633
634 #[tokio::test]
635 #[ignore = "Ignore by default since it requires a running server and network access"]
636 async fn test_integration_model_request_tiny_model() {
637 wait_for_server_startup().await;
638
639 if let Some(mut client) = try_create_client().await {
640 let result = client
643 .request_model_server_only("sentence-transformers/all-MiniLM-L6-v2", false)
644 .await;
645
646 match result {
649 Ok(()) => info!("Model download successful"),
650 Err(e) => warn!("Model download failed (expected in unit test env): {e}"),
651 }
652 } else {
653 info!("Skipping integration test - server not available");
654 }
655 }
656}