Skip to main content

modelexpress_client/
lib.rs

1// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4use modelexpress_common::providers::ModelProviderTrait;
5use modelexpress_common::{
6    Result as CommonResult,
7    cache::{CacheConfig, CacheStats},
8    client_config::ClientConfig as Config,
9    constants, download,
10    grpc::{
11        api::{ApiRequest, api_service_client::ApiServiceClient},
12        health::{HealthRequest, health_service_client::HealthServiceClient},
13        model::{
14            ModelDownloadRequest, ModelFilesRequest, model_service_client::ModelServiceClient,
15        },
16    },
17    models::{ModelStatus, Status},
18    providers::huggingface::HuggingFaceProvider,
19};
20use std::collections::HashMap;
21use std::path::{Component, PathBuf};
22use std::time::Duration;
23use tokio::io::AsyncWriteExt;
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 modelexpress_common::client_config::{ClientArgs, ClientConfig};
33pub use modelexpress_common::models::ModelProvider;
34
35/// The main client for interacting with the `modelexpress_server` via gRPC
36pub struct Client {
37    health_client: HealthServiceClient<Channel>,
38    api_client: ApiServiceClient<Channel>,
39    model_client: ModelServiceClient<Channel>,
40    cache_config: Option<CacheConfig>,
41}
42
43impl Client {
44    /// Create a new client with the given configuration
45    pub async fn new(config: Config) -> CommonResult<Self> {
46        let endpoint = &config.connection.endpoint;
47        let timeout = config
48            .connection
49            .timeout_secs
50            .unwrap_or(constants::DEFAULT_TIMEOUT_SECS);
51
52        let channel = tonic::transport::Endpoint::new(endpoint.clone())
53            .map(|endpoint| endpoint.timeout(Duration::from_secs(timeout)))?
54            .connect()
55            .await?;
56
57        let health_client = HealthServiceClient::new(channel.clone());
58        let api_client = ApiServiceClient::new(channel.clone());
59        let model_client = ModelServiceClient::new(channel);
60
61        // Use the cache config from the client configuration
62        let cache_config = Some(config.cache.clone());
63
64        Ok(Self {
65            health_client,
66            api_client,
67            model_client,
68            cache_config,
69        })
70    }
71
72    /// Create a new client with cache configuration
73    pub async fn new_with_cache(config: Config, cache_config: CacheConfig) -> CommonResult<Self> {
74        let mut client = Self::new(config).await?;
75        client.cache_config = Some(cache_config);
76        Ok(client)
77    }
78
79    /// Get cache configuration
80    pub fn get_cache_config(&self) -> Option<&CacheConfig> {
81        self.cache_config.as_ref()
82    }
83
84    /// Set cache configuration
85    pub fn set_cache_config(&mut self, cache_config: CacheConfig) {
86        self.cache_config = Some(cache_config);
87    }
88
89    /// List cached models
90    pub fn list_cached_models(&self) -> CommonResult<CacheStats> {
91        let cache_config = self.cache_config.as_ref().ok_or_else(|| {
92            modelexpress_common::Error::Server("Cache not configured".to_string())
93        })?;
94
95        cache_config.get_cache_stats().map_err(|e| {
96            modelexpress_common::Error::Server(format!("Failed to get cache stats: {e}")).into()
97        })
98    }
99
100    /// Clear specific model from cache
101    pub fn clear_cached_model(&self, model_name: &str) -> CommonResult<()> {
102        let cache_config = self.cache_config.as_ref().ok_or_else(|| {
103            modelexpress_common::Error::Server("Cache not configured".to_string())
104        })?;
105
106        cache_config.clear_model(model_name).map_err(|e| {
107            modelexpress_common::Error::Server(format!("Failed to clear model: {e}")).into()
108        })
109    }
110
111    /// Clear entire cache
112    pub fn clear_all_cached_models(&self) -> CommonResult<()> {
113        let cache_config = self.cache_config.as_ref().ok_or_else(|| {
114            modelexpress_common::Error::Server("Cache not configured".to_string())
115        })?;
116
117        cache_config.clear_all().map_err(|e| {
118            modelexpress_common::Error::Server(format!("Failed to clear cache: {e}")).into()
119        })
120    }
121
122    /// Get the cache directory path, using client's cache config if available,
123    /// otherwise discovering from environment variables and defaults.
124    fn get_cache_dir(&self) -> PathBuf {
125        // Check Hugging Face's HF_HUB_CACHE environment variable first,
126        // as it takes precedence, and isn't specific to our client.
127        use std::env;
128        if let Ok(cache_path) = env::var("HF_HUB_CACHE") {
129            return PathBuf::from(cache_path);
130        }
131        // Use client's cache config if available
132        if let Some(cache_config) = &self.cache_config {
133            return cache_config.local_path.clone();
134        }
135
136        // Fall back to discovery
137        CacheConfig::discover()
138            .map(|config| config.local_path)
139            .unwrap_or_else(|_| CacheConfig::default().local_path)
140    }
141
142    pub async fn get_model_path(&self, model_name: &str) -> anyhow::Result<PathBuf> {
143        let cache_dir = self.get_cache_dir();
144        let model_path = HuggingFaceProvider
145            .get_model_path(model_name, cache_dir)
146            .await
147            .map_err(|e| anyhow::anyhow!(format!("Failed to get model path: {e}")))?;
148
149        debug!("Found model path at {:?}", model_path);
150        Ok(model_path)
151    }
152
153    /// Pre-download model to cache
154    /// When shared_storage is disabled, files will be streamed from server to client.
155    pub async fn preload_model_to_cache(
156        &mut self,
157        model_name: &str,
158        provider: ModelProvider,
159        ignore_weights: bool,
160    ) -> CommonResult<()> {
161        info!("Pre-loading model {} to cache", model_name);
162
163        // First try to download via server
164        match self
165            .request_model_with_provider(model_name, provider, ignore_weights)
166            .await
167        {
168            Ok(()) => {
169                info!("Model {} pre-loaded successfully via server", model_name);
170
171                // Check if we need to stream files from server (no shared storage)
172                let needs_streaming = self
173                    .cache_config
174                    .as_ref()
175                    .is_some_and(|c| !c.shared_storage);
176
177                if needs_streaming {
178                    info!(
179                        "Shared storage disabled, streaming files from server for model {}",
180                        model_name
181                    );
182                    self.stream_model_files_from_server(model_name, provider)
183                        .await?;
184                }
185
186                Ok(())
187            }
188            Err(e) => {
189                // Fallback to direct download
190                info!(
191                    "Server unavailable, pre-loading model {} directly. Error: {}",
192                    model_name, e
193                );
194                Self::download_model_directly(model_name, provider, ignore_weights).await
195            }
196        }
197    }
198
199    /// Get the server health status
200    pub async fn health_check(&mut self) -> CommonResult<Status> {
201        let request = tonic::Request::new(HealthRequest {});
202
203        let response = self.health_client.get_health(request).await?;
204        let health_response = response.into_inner();
205
206        Ok(health_response.into())
207    }
208
209    /// Send a request to the server
210    pub async fn send_request<T: serde::de::DeserializeOwned>(
211        &mut self,
212        action: impl Into<String>,
213        payload: Option<HashMap<String, serde_json::Value>>,
214    ) -> CommonResult<T> {
215        let request_id = Uuid::new_v4().to_string();
216
217        let payload_bytes = if let Some(payload) = payload {
218            Some(
219                serde_json::to_vec(&payload)
220                    .map_err(|e| modelexpress_common::Error::Serialization(e.to_string()))?,
221            )
222        } else {
223            None
224        };
225
226        let grpc_request = tonic::Request::new(ApiRequest {
227            id: request_id,
228            action: action.into(),
229            payload: payload_bytes,
230        });
231
232        let response = self.api_client.send_request(grpc_request).await?;
233        let api_response = response.into_inner();
234
235        if !api_response.success {
236            return Err(modelexpress_common::Error::Server(
237                api_response
238                    .error
239                    .unwrap_or_else(|| "Unknown server error".to_string()),
240            )
241            .into());
242        }
243
244        let data_bytes = api_response.data.ok_or_else(|| {
245            modelexpress_common::Error::Server("Server returned success but no data".to_string())
246        })?;
247
248        let data: T = serde_json::from_slice(&data_bytes)
249            .map_err(|e| modelexpress_common::Error::Serialization(e.to_string()))?;
250
251        Ok(data)
252    }
253
254    /// Request a model from the server with a specific provider and automatic fallback
255    /// This function will first try to use the server for streaming downloads.
256    /// If the server is unavailable, it will fallback to downloading directly.
257    /// When shared_storage is disabled, files will be streamed from server to client.
258    pub async fn request_model_with_provider_and_fallback(
259        &mut self,
260        model_name: impl Into<String>,
261        provider: ModelProvider,
262        ignore_weights: bool,
263    ) -> CommonResult<()> {
264        let model_name = model_name.into();
265
266        // First try the server-based approach
267        match self
268            .request_model_with_provider(&model_name, provider, ignore_weights)
269            .await
270        {
271            Ok(()) => {
272                info!("Model {} downloaded successfully via server", model_name);
273
274                // Check if we need to stream files from server (no shared storage)
275                let needs_streaming = self
276                    .cache_config
277                    .as_ref()
278                    .is_some_and(|c| !c.shared_storage);
279
280                if needs_streaming {
281                    info!(
282                        "Shared storage disabled, streaming files from server for model {}",
283                        model_name
284                    );
285                    self.stream_model_files_from_server(&model_name, provider)
286                        .await?;
287                }
288
289                Ok(())
290            }
291            Err(e) => {
292                // Check if it's a connection error (server not available)
293                if let modelexpress_common::Error::Transport(_) = *e {
294                    info!(
295                        "Server unavailable, falling back to direct download for model: {}",
296                        model_name
297                    );
298
299                    // Fallback to direct download
300                    let cache_dir = CacheConfig::discover().ok().map(|config| config.local_path);
301                    match download::download_model(&model_name, provider, cache_dir, ignore_weights).await {
302                        Ok(_) => {
303                            info!(
304                                "Model {} downloaded successfully via direct download",
305                                model_name
306                            );
307                            Ok(())
308                        }
309                        Err(download_err) => Err(modelexpress_common::Error::Server(format!(
310                            "Both server and direct download failed. Server error: {e}. Download error: {download_err}"
311                        )).into()),
312                    }
313                } else {
314                    // For other types of errors, don't fallback
315                    Err(e)
316                }
317            }
318        }
319    }
320
321    /// Stream model files from the server to the local cache
322    /// This is used when shared storage is disabled
323    pub async fn stream_model_files_from_server(
324        &mut self,
325        model_name: &str,
326        provider: ModelProvider,
327    ) -> CommonResult<()> {
328        let cache_config = self.cache_config.as_ref().ok_or_else(|| {
329            modelexpress_common::Error::Server(
330                "Cache configuration is required for file streaming. Please ensure cache_config is set.".to_string()
331            )
332        })?;
333
334        let chunk_size = cache_config.transfer_chunk_size as u32;
335        let local_cache_path = cache_config.local_path.clone();
336
337        info!(
338            "Streaming model {} files to {:?} with chunk size {} bytes",
339            model_name, local_cache_path, chunk_size
340        );
341
342        let grpc_request = tonic::Request::new(ModelFilesRequest {
343            model_name: model_name.to_string(),
344            provider: modelexpress_common::grpc::model::ModelProvider::from(provider) as i32,
345            chunk_size,
346        });
347
348        let mut stream = self
349            .model_client
350            .stream_model_files(grpc_request)
351            .await?
352            .into_inner();
353
354        // Create a normalized model directory path (similar to HuggingFace cache structure)
355        let normalized_name = model_name.replace('/', "--");
356        let snapshots_dir = local_cache_path
357            .join(format!("models--{}", normalized_name))
358            .join("snapshots");
359
360        // Model directory will be set after receiving the first chunk with commit hash
361        let mut model_dir: Option<PathBuf> = None;
362        let mut canonical_model_dir: Option<PathBuf> = None;
363
364        let mut current_file: Option<(PathBuf, tokio::fs::File)> = None;
365        let mut files_received: u64 = 0;
366        let mut bytes_received: u64 = 0;
367
368        while let Some(chunk_result) = stream.message().await? {
369            // Extract commit hash from first chunk and create model directory
370            if model_dir.is_none() {
371                let commit_hash = chunk_result.commit_hash.as_ref().ok_or_else(|| {
372                    modelexpress_common::Error::Server(
373                        "Server did not provide commit hash in first chunk".to_string(),
374                    )
375                })?;
376
377                let dir = snapshots_dir.join(commit_hash);
378                std::fs::create_dir_all(&dir).map_err(|e| {
379                    modelexpress_common::Error::Server(format!(
380                        "Failed to create model directory: {e}"
381                    ))
382                })?;
383
384                // Canonicalize model_dir once for efficiency and security
385                let canonical_dir = dir.canonicalize().map_err(|e| {
386                    modelexpress_common::Error::Server(format!(
387                        "Failed to canonicalize model directory: {e}"
388                    ))
389                })?;
390
391                model_dir = Some(dir);
392                canonical_model_dir = Some(canonical_dir);
393            }
394
395            let model_dir_ref = model_dir.as_ref().ok_or_else(|| {
396                modelexpress_common::Error::Server("Model directory not initialized".to_string())
397            })?;
398            let canonical_model_dir_ref = canonical_model_dir.as_ref().ok_or_else(|| {
399                modelexpress_common::Error::Server(
400                    "Canonical model directory not initialized".to_string(),
401                )
402            })?;
403
404            let relative_path = PathBuf::from(&chunk_result.relative_path);
405
406            // Validate that the relative path does not contain any '..' components or is absolute
407            let is_safe = !relative_path.components().any(|c| {
408                matches!(
409                    c,
410                    Component::ParentDir | Component::RootDir | Component::Prefix(_)
411                )
412            });
413
414            if !is_safe {
415                return Err(Box::new(modelexpress_common::Error::Server(format!(
416                    "Received potentially unsafe file path from server: {:?}",
417                    chunk_result.relative_path
418                ))));
419            }
420
421            let file_path = model_dir_ref.join(&relative_path);
422
423            // Verify that the resolved path is still within model_dir
424            // Create parent directory first if it doesn't exist to enable proper validation
425            if let Some(parent) = file_path.parent() {
426                if !parent.exists() {
427                    std::fs::create_dir_all(parent).map_err(|e| {
428                        modelexpress_common::Error::Server(format!(
429                            "Failed to create parent directory: {e}"
430                        ))
431                    })?;
432                }
433
434                let canonical_parent = parent.canonicalize().map_err(|e| {
435                    modelexpress_common::Error::Server(format!(
436                        "Failed to canonicalize parent directory: {e}"
437                    ))
438                })?;
439
440                if !canonical_parent.starts_with(canonical_model_dir_ref) {
441                    return Err(Box::new(modelexpress_common::Error::Server(format!(
442                        "Received file path that resolves outside model directory: {:?}",
443                        chunk_result.relative_path
444                    ))));
445                }
446            }
447
448            // If this is a new file (different path or first chunk)
449            let need_new_file = current_file
450                .as_ref()
451                .is_none_or(|(path, _)| path != &file_path);
452
453            if need_new_file {
454                // Close previous file if any
455                if let Some((prev_path, file)) = current_file.take() {
456                    file.sync_all().await.map_err(|e| {
457                        modelexpress_common::Error::Server(format!(
458                            "Failed to sync file to disk {:?}: {e}",
459                            prev_path
460                        ))
461                    })?;
462                    drop(file);
463                    debug!("Finished writing file: {:?}", prev_path);
464                }
465
466                // Create new file (parent directory was already created during validation)
467                let file = tokio::fs::File::create(&file_path).await.map_err(|e| {
468                    modelexpress_common::Error::Server(format!(
469                        "Failed to create file {:?}: {e}",
470                        file_path
471                    ))
472                })?;
473
474                debug!(
475                    "Starting to receive file: {:?} ({} bytes)",
476                    relative_path, chunk_result.total_size
477                );
478                files_received = files_received.saturating_add(1);
479                current_file = Some((file_path.clone(), file));
480            }
481
482            // Write chunk to file
483            if let Some((_, ref mut file)) = current_file {
484                file.write_all(&chunk_result.data).await.map_err(|e| {
485                    modelexpress_common::Error::Server(format!("Failed to write to file: {e}"))
486                })?;
487                bytes_received = bytes_received.saturating_add(chunk_result.data.len() as u64);
488            }
489
490            // Check if we're done with all files
491            if chunk_result.is_last_file && chunk_result.is_last_chunk {
492                break;
493            }
494        }
495
496        // Ensure the last file is properly closed
497        if let Some((path, file)) = current_file.take() {
498            file.sync_all().await.map_err(|e| {
499                modelexpress_common::Error::Server(format!(
500                    "Failed to sync final file to disk {:?}: {e}",
501                    path
502                ))
503            })?;
504            drop(file);
505            debug!("Finished writing final file: {:?}", path);
506        }
507
508        info!(
509            "Streaming complete: received {} files ({} bytes) for model {}",
510            files_received, bytes_received, model_name
511        );
512
513        Ok(())
514    }
515
516    /// Request a model from the server with a specific provider
517    /// This function will wait until the model is downloaded using streaming updates
518    pub async fn request_model_with_provider(
519        &mut self,
520        model_name: impl Into<String>,
521        provider: ModelProvider,
522        ignore_weights: bool,
523    ) -> CommonResult<()> {
524        let model_name = model_name.into();
525        info!(
526            "Requesting model: {} from provider: {:?}",
527            model_name, provider
528        );
529
530        let grpc_request = tonic::Request::new(ModelDownloadRequest {
531            model_name: model_name.clone(),
532            provider: modelexpress_common::grpc::model::ModelProvider::from(provider) as i32,
533            ignore_weights,
534        });
535
536        let mut stream = self
537            .model_client
538            .ensure_model_downloaded(grpc_request)
539            .await?
540            .into_inner();
541
542        // Process streaming updates until the download is complete
543        while let Some(update_result) = stream.message().await? {
544            let status: ModelStatus =
545                modelexpress_common::grpc::model::ModelStatus::try_from(update_result.status)
546                    .unwrap_or(modelexpress_common::grpc::model::ModelStatus::Error)
547                    .into();
548
549            // Log progress messages if available
550            if let Some(message) = &update_result.message {
551                info!("Model {}: {}", model_name, message);
552            } else {
553                info!("Model {} status: {:?}", model_name, status);
554            }
555
556            // Check if we're done
557            match status {
558                ModelStatus::DOWNLOADED => {
559                    info!("Model {} is now available", model_name);
560                    return Ok(());
561                }
562                ModelStatus::ERROR => {
563                    let error_message = update_result
564                        .message
565                        .unwrap_or_else(|| "Unknown error occurred".to_string());
566                    return Err(modelexpress_common::Error::Server(format!(
567                        "Model download failed: {error_message}"
568                    ))
569                    .into());
570                }
571                ModelStatus::DOWNLOADING => {
572                    // Continue processing updates
573                    continue;
574                }
575            }
576        }
577
578        // If stream ended without DOWNLOADED status, treat as error
579        Err(modelexpress_common::Error::Server(
580            "Model download stream ended unexpectedly".to_string(),
581        )
582        .into())
583    }
584
585    /// Request a model from the server using the default provider (Hugging Face) with automatic fallback
586    /// This function will first try to use the server, then fallback to direct download if needed
587    pub async fn request_model(
588        &mut self,
589        model_name: impl Into<String>,
590        ignore_weights: bool,
591    ) -> CommonResult<()> {
592        self.request_model_with_provider_and_fallback(
593            model_name,
594            ModelProvider::default(),
595            ignore_weights,
596        )
597        .await
598    }
599
600    /// Request a model from the server only (no fallback)
601    /// This function will wait until the model is downloaded using streaming updates from the server
602    pub async fn request_model_server_only(
603        &mut self,
604        model_name: impl Into<String>,
605        ignore_weights: bool,
606    ) -> CommonResult<()> {
607        self.request_model_with_provider(model_name, ModelProvider::default(), ignore_weights)
608            .await
609    }
610
611    /// Request a model with automatic server fallback, creating client connection only if needed
612    /// This function will try to download via server if possible, otherwise download directly
613    pub async fn request_model_with_smart_fallback(
614        model_name: impl Into<String>,
615        provider: ModelProvider,
616        config: Config,
617        ignore_weights: bool,
618    ) -> CommonResult<()> {
619        let model_name = model_name.into();
620
621        // First try to create a client and use server-based download
622        match Client::new(config.clone()).await {
623            Ok(mut client) => {
624                info!("Server connection established, downloading via server...");
625                client
626                    .request_model_with_provider_and_fallback(&model_name, provider, ignore_weights)
627                    .await
628            }
629            Err(e) => {
630                // If we can't even connect to the server, go straight to direct download
631                info!("Cannot connect to server ({}), downloading directly...", e);
632                Client::download_model_directly(&model_name, provider, ignore_weights).await
633            }
634        }
635    }
636
637    /// Download a model directly without using the server
638    /// This bypasses the server entirely and downloads the model using the specified provider
639    pub async fn download_model_directly(
640        model_name: impl Into<String>,
641        provider: ModelProvider,
642        ignore_weights: bool,
643    ) -> CommonResult<()> {
644        let model_name = model_name.into();
645        info!(
646            "Downloading model {} directly using provider: {:?}",
647            model_name, provider
648        );
649
650        // Try to get cache configuration, but don't fail if not available
651        let cache_dir = CacheConfig::discover().ok().map(|config| config.local_path);
652
653        download::download_model(&model_name, provider, cache_dir, ignore_weights)
654            .await
655            .map_err(|e| {
656                modelexpress_common::Error::Server(format!("Direct download failed: {e}"))
657            })?;
658
659        info!("Model {} downloaded successfully", model_name);
660        Ok(())
661    }
662}
663
664#[cfg(test)]
665#[allow(clippy::expect_used)]
666mod tests {
667    use super::*;
668    use modelexpress_common::cache::CacheConfig;
669    use std::collections::HashMap;
670
671    fn create_test_client_config() -> ClientConfig {
672        ClientConfig::for_testing("http://test-endpoint:1234")
673    }
674
675    #[test]
676    fn test_client_config_creation() {
677        let config = create_test_client_config();
678        assert_eq!(config.connection.endpoint, "http://test-endpoint:1234");
679    }
680
681    #[test]
682    fn test_client_config_default_timeout() {
683        let config = create_test_client_config();
684        assert!(config.connection.timeout_secs.is_some());
685    }
686
687    #[test]
688    fn test_client_config_with_timeout() {
689        let mut config = create_test_client_config();
690        config.connection.timeout_secs = Some(60);
691        assert_eq!(config.connection.timeout_secs, Some(60));
692    }
693
694    #[test]
695    fn test_endpoint_override_priority() {
696        // This test verifies that the configuration works correctly
697        let config = ClientConfig::for_testing("http://command-line-endpoint:5678");
698
699        // Test that the config has the correct endpoint
700        assert_eq!(
701            config.connection.endpoint,
702            "http://command-line-endpoint:5678"
703        );
704    }
705
706    #[test]
707    fn test_cache_config_endpoint_override() {
708        // Test that cache config can be created with a specific endpoint
709        let mut cache_config = CacheConfig {
710            local_path: std::path::PathBuf::from("/test/path"),
711            server_endpoint: "http://original-endpoint:1234".to_string(),
712            timeout_secs: None,
713            shared_storage: true,
714            transfer_chunk_size: modelexpress_common::constants::DEFAULT_TRANSFER_CHUNK_SIZE,
715        };
716
717        // Simulate endpoint override
718        cache_config.server_endpoint = "http://override-endpoint:5678".to_string();
719
720        assert_eq!(
721            cache_config.server_endpoint,
722            "http://override-endpoint:5678"
723        );
724    }
725
726    // Note: Most client tests require a running server, so they would be integration tests
727    // These unit tests focus on the configuration and setup logic
728
729    #[tokio::test]
730    async fn test_client_new_with_invalid_endpoint() {
731        let config = ClientConfig::for_testing("invalid-endpoint");
732
733        let result = Client::new(config).await;
734        assert!(result.is_err());
735    }
736
737    #[tokio::test]
738    async fn test_download_model_directly_invalid_model() {
739        // This test may fail if network is available and HF is accessible
740        // In a real test environment, you might want to mock the download function
741        let result = Client::download_model_directly(
742            "definitely-not-a-real-model-name-12345",
743            ModelProvider::HuggingFace,
744            false,
745        )
746        .await;
747
748        // Should fail for a non-existent model
749        assert!(result.is_err());
750    }
751
752    #[test]
753    fn test_model_provider_enum() {
754        let provider = ModelProvider::HuggingFace;
755        assert_eq!(provider, ModelProvider::HuggingFace);
756
757        let default_provider = ModelProvider::default();
758        assert_eq!(default_provider, ModelProvider::HuggingFace);
759    }
760
761    #[test]
762    fn test_serialization_payload_creation() {
763        let mut payload = HashMap::new();
764        payload.insert(
765            "key1".to_string(),
766            serde_json::Value::String("value1".to_string()),
767        );
768        payload.insert(
769            "key2".to_string(),
770            serde_json::Value::Number(serde_json::Number::from(42)),
771        );
772
773        let payload_bytes =
774            serde_json::to_vec(&payload).expect("Serialization should not fail in test");
775        let deserialized: HashMap<String, serde_json::Value> =
776            serde_json::from_slice(&payload_bytes)
777                .expect("Deserialization should not fail in test");
778
779        assert_eq!(payload, deserialized);
780    }
781
782    #[tokio::test]
783    async fn test_request_model_with_smart_fallback_no_server() {
784        // Test the smart fallback when server is not available
785        let config = Config::for_testing("http://127.0.0.1:99999"); // Invalid port
786
787        let result = Client::request_model_with_smart_fallback(
788            "definitely-not-a-real-model-name-12345",
789            ModelProvider::HuggingFace,
790            config,
791            false,
792        )
793        .await;
794
795        // Should fail because the model doesn't exist, but we should get past the connection attempt
796        assert!(result.is_err());
797        // The error should indicate it tried to connect to the server but failed
798        let error_msg = result.expect_err("Result should be an error").to_string();
799        assert!(
800            error_msg.contains("Direct download failed")
801                || error_msg.contains("Cannot connect")
802                || error_msg.contains("gRPC error")
803                || error_msg.contains("h2 protocol error")
804                || error_msg.contains("connection")
805        );
806    }
807}
808
809#[cfg(test)]
810#[allow(clippy::expect_used)]
811mod integration_tests {
812    use super::*;
813    use modelexpress_common::constants;
814    use std::time::Duration;
815    use tokio::time::timeout;
816
817    // These tests require a running server and are more appropriate as integration tests
818    // They are included here but should be run separately from unit tests
819
820    async fn wait_for_server_startup() {
821        // Give the server time to start up in CI environments
822        tokio::time::sleep(Duration::from_millis(100)).await;
823    }
824
825    async fn try_create_client() -> Option<Client> {
826        let config =
827            Config::for_testing(format!("http://127.0.0.1:{}", constants::DEFAULT_GRPC_PORT));
828
829        match timeout(Duration::from_secs(2), Client::new(config)).await {
830            Ok(Ok(client)) => Some(client),
831            _ => None,
832        }
833    }
834
835    #[tokio::test]
836    #[ignore = "Ignore by default since it requires a running server"]
837    async fn test_integration_health_check() {
838        wait_for_server_startup().await;
839
840        if let Some(mut client) = try_create_client().await {
841            let result = client.health_check().await;
842            assert!(result.is_ok());
843
844            let status = result.expect("Health check should succeed");
845            assert!(!status.version.is_empty());
846            assert_eq!(status.status, "ok");
847            // uptime is u64, so it's always >= 0, just check it exists
848            let _uptime = status.uptime;
849        } else {
850            info!("Skipping integration test - server not available");
851        }
852    }
853
854    #[tokio::test]
855    #[ignore = "Ignore by default since it requires a running server"]
856    async fn test_integration_ping_request() {
857        wait_for_server_startup().await;
858
859        if let Some(mut client) = try_create_client().await {
860            let result: Result<serde_json::Value, _> = client.send_request("ping", None).await;
861            assert!(result.is_ok());
862
863            let response = result.expect("Ping request should succeed");
864            assert_eq!(response["message"], "pong");
865        } else {
866            info!("Skipping integration test - server not available");
867        }
868    }
869
870    #[tokio::test]
871    #[ignore = "Ignore by default since it requires a running server and network access"]
872    async fn test_integration_model_request_tiny_model() {
873        wait_for_server_startup().await;
874
875        if let Some(mut client) = try_create_client().await {
876            // Use a very small model for testing to avoid long download times
877            // Note: This test might still take time and requires network access
878            let result = client
879                .request_model_server_only("sentence-transformers/all-MiniLM-L6-v2", false)
880                .await;
881
882            // We don't assert success here because it depends on network availability
883            // In a real integration test environment, you might use a mock model
884            match result {
885                Ok(()) => info!("Model download successful"),
886                Err(e) => warn!("Model download failed (expected in unit test env): {e}"),
887            }
888        } else {
889            info!("Skipping integration test - server not available");
890        }
891    }
892}