modelexpress_client/
lib.rs

1// SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4mod 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
31// Re-export for public use
32pub use crate::error::ClientError;
33pub use modelexpress_common::client_config::{ClientArgs, ClientConfig};
34pub use modelexpress_common::models::ModelProvider;
35
36/// The main client for interacting with the `modelexpress_server` via gRPC
37pub 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    /// Create a new client with the given configuration
46    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        // Use the cache config from the client configuration
63        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    /// Create a new client with cache configuration
74    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    /// Get cache configuration
81    pub fn get_cache_config(&self) -> Option<&CacheConfig> {
82        self.cache_config.as_ref()
83    }
84
85    /// Set cache configuration
86    pub fn set_cache_config(&mut self, cache_config: CacheConfig) {
87        self.cache_config = Some(cache_config);
88    }
89
90    /// List cached models
91    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    /// Clear specific model from cache
102    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    /// Clear entire cache
113    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    /// TODO: change to using the current client cache dir
124    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    /// Pre-download model to cache
149    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        // First try to download via server
158        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                // Fallback to direct download
168                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    /// Get the server health status
178    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    /// Send a request to the server
188    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    /// Request a model from the server with a specific provider and automatic fallback
233    /// This function will first try to use the server for streaming downloads.
234    /// If the server is unavailable, it will fallback to downloading directly.
235    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        // First try the server-based approach
244        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                // Check if it's a connection error (server not available)
254                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                    // Fallback to direct download
261                    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                    // For other types of errors, don't fallback
276                    Err(e)
277                }
278            }
279        }
280    }
281
282    /// Request a model from the server with a specific provider
283    /// This function will wait until the model is downloaded using streaming updates
284    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        // Process streaming updates until the download is complete
309        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            // Log progress messages if available
316            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            // Check if we're done
323            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 processing updates
339                    continue;
340                }
341            }
342        }
343
344        // If stream ended without DOWNLOADED status, treat as error
345        Err(modelexpress_common::Error::Server(
346            "Model download stream ended unexpectedly".to_string(),
347        )
348        .into())
349    }
350
351    /// Request a model from the server using the default provider (Hugging Face) with automatic fallback
352    /// This function will first try to use the server, then fallback to direct download if needed
353    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    /// Request a model from the server only (no fallback)
367    /// This function will wait until the model is downloaded using streaming updates from the server
368    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    /// Request a model with automatic server fallback, creating client connection only if needed
378    /// This function will try to download via server if possible, otherwise download directly
379    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        // First try to create a client and use server-based download
388        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                // If we can't even connect to the server, go straight to direct download
397                info!("Cannot connect to server ({}), downloading directly...", e);
398                Client::download_model_directly(&model_name, provider, ignore_weights).await
399            }
400        }
401    }
402
403    /// Download a model directly without using the server
404    /// This bypasses the server entirely and downloads the model using the specified provider
405    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        // Try to get cache configuration, but don't fail if not available
417        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        // This test verifies that the configuration works correctly
463        let config = ClientConfig::for_testing("http://command-line-endpoint:5678");
464
465        // Test that the config has the correct endpoint
466        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        // Test that cache config can be created with a specific endpoint
475        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        // Simulate endpoint override
482        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    // Note: Most client tests require a running server, so they would be integration tests
491    // These unit tests focus on the configuration and setup logic
492
493    #[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        // This test may fail if network is available and HF is accessible
504        // In a real test environment, you might want to mock the download function
505        let result = Client::download_model_directly(
506            "definitely-not-a-real-model-name-12345",
507            ModelProvider::HuggingFace,
508            false,
509        )
510        .await;
511
512        // Should fail for a non-existent model
513        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        // Test the smart fallback when server is not available
549        let config = Config::for_testing("http://127.0.0.1:99999"); // Invalid port
550
551        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        // Should fail because the model doesn't exist, but we should get past the connection attempt
560        assert!(result.is_err());
561        // The error should indicate it tried to connect to the server but failed
562        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    // These tests require a running server and are more appropriate as integration tests
582    // They are included here but should be run separately from unit tests
583
584    async fn wait_for_server_startup() {
585        // Give the server time to start up in CI environments
586        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            // uptime is u64, so it's always >= 0, just check it exists
612            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            // Use a very small model for testing to avoid long download times
641            // Note: This test might still take time and requires network access
642            let result = client
643                .request_model_server_only("sentence-transformers/all-MiniLM-L6-v2", false)
644                .await;
645
646            // We don't assert success here because it depends on network availability
647            // In a real integration test environment, you might use a mock model
648            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}