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::{
5    Result as CommonResult,
6    cache::{CacheConfig, CacheStats, resolve_model_path},
7    client_config::ClientConfig as Config,
8    constants, download,
9    grpc::{
10        api::{ApiRequest, api_service_client::ApiServiceClient},
11        health::{HealthRequest, health_service_client::HealthServiceClient},
12        model::{
13            ModelDownloadRequest, ModelFilesRequest, model_service_client::ModelServiceClient,
14        },
15    },
16    models::{ModelStatus, Status},
17};
18use std::collections::HashMap;
19use std::path::{Component, PathBuf};
20use std::time::Duration;
21use tokio::io::AsyncWriteExt;
22use tonic::transport::Channel;
23use tracing::debug;
24use tracing::info;
25#[cfg(test)]
26use tracing::warn;
27use uuid::Uuid;
28
29// Re-export for public use
30pub use modelexpress_common::client_config::{ClientArgs, ClientConfig};
31pub use modelexpress_common::models::ModelProvider;
32
33/// The main client for interacting with the `modelexpress_server` via gRPC
34pub struct Client {
35    health_client: HealthServiceClient<Channel>,
36    api_client: ApiServiceClient<Channel>,
37    model_client: ModelServiceClient<Channel>,
38    cache_config: Option<CacheConfig>,
39}
40
41fn is_safe_relative_path(path: &std::path::Path) -> bool {
42    path.components()
43        .any(|component| matches!(component, Component::Normal(_)))
44        && !path.components().any(|component| {
45            matches!(
46                component,
47                Component::ParentDir | Component::RootDir | Component::Prefix(_)
48            )
49        })
50}
51
52fn prepare_stream_model_dir(
53    local_cache_path: &std::path::Path,
54    provider: ModelProvider,
55    model_name: &str,
56    commit_hash: Option<&str>,
57) -> CommonResult<(PathBuf, PathBuf)> {
58    if provider == ModelProvider::HuggingFace && commit_hash.is_none() {
59        return Err(modelexpress_common::Error::Validation(format!(
60            "Failed to resolve model directory: missing required revision for provider {:?}",
61            provider
62        ))
63        .into());
64    }
65
66    let model_dir = resolve_model_path(local_cache_path, provider, model_name, commit_hash)
67        .map_err(|e| {
68            modelexpress_common::Error::Io(format!("Failed to resolve model directory: {e}"))
69        })?;
70
71    std::fs::create_dir_all(&model_dir).map_err(|e| {
72        modelexpress_common::Error::Io(format!("Failed to create model directory: {e}"))
73    })?;
74
75    let canonical_cache_dir = local_cache_path.canonicalize().map_err(|e| {
76        modelexpress_common::Error::Io(format!(
77            "Failed to canonicalize cache directory {:?}: {e}",
78            local_cache_path
79        ))
80    })?;
81    let canonical_model_dir = model_dir.canonicalize().map_err(|e| {
82        modelexpress_common::Error::Io(format!("Failed to canonicalize model directory: {e}"))
83    })?;
84
85    if !canonical_model_dir.starts_with(&canonical_cache_dir) {
86        return Err(modelexpress_common::Error::Validation(format!(
87            "Received model directory that resolves outside cache root: {:?}",
88            model_dir
89        ))
90        .into());
91    }
92
93    Ok((model_dir, canonical_model_dir))
94}
95
96async fn create_stream_output_file(file_path: &std::path::Path) -> CommonResult<tokio::fs::File> {
97    match std::fs::symlink_metadata(file_path) {
98        Ok(metadata) => {
99            if metadata.is_dir() {
100                return Err(modelexpress_common::Error::Validation(format!(
101                    "Refusing to overwrite directory in model directory: {:?}",
102                    file_path
103                ))
104                .into());
105            }
106
107            if !metadata.is_file() && !metadata.file_type().is_symlink() {
108                return Err(modelexpress_common::Error::Validation(format!(
109                    "Refusing to overwrite unsupported file type in model directory: {:?}",
110                    file_path
111                ))
112                .into());
113            }
114
115            // Existing HF snapshot entries may be symlinks into `blobs/`.
116            // Unlink the final path itself so we never follow it when recreating the file.
117            std::fs::remove_file(file_path).map_err(|e| {
118                modelexpress_common::Error::Io(format!(
119                    "Failed to replace existing file {:?}: {e}",
120                    file_path
121                ))
122            })?;
123        }
124        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
125        Err(e) => {
126            return Err(modelexpress_common::Error::Io(format!(
127                "Failed to inspect output file {:?}: {e}",
128                file_path
129            ))
130            .into());
131        }
132    }
133
134    // `create_new` ensures a final-path symlink introduced after the metadata
135    // check is rejected instead of being followed.
136    tokio::fs::OpenOptions::new()
137        .write(true)
138        .create_new(true)
139        .open(file_path)
140        .await
141        .map_err(|e| {
142            modelexpress_common::Error::Io(format!("Failed to create file {:?}: {e}", file_path))
143                .into()
144        })
145}
146
147impl Client {
148    /// Create a new client with the given configuration
149    pub async fn new(config: Config) -> CommonResult<Self> {
150        let endpoint = &config.connection.endpoint;
151        let timeout = config
152            .connection
153            .timeout_secs
154            .unwrap_or(constants::DEFAULT_TIMEOUT_SECS);
155
156        let channel = tonic::transport::Endpoint::new(endpoint.clone())
157            .map(|endpoint| endpoint.timeout(Duration::from_secs(timeout)))?
158            .connect()
159            .await?;
160
161        let health_client = HealthServiceClient::new(channel.clone());
162        let api_client = ApiServiceClient::new(channel.clone());
163        let model_client = ModelServiceClient::new(channel);
164
165        // Use the cache config from the client configuration
166        let cache_config = Some(config.cache.clone());
167
168        Ok(Self {
169            health_client,
170            api_client,
171            model_client,
172            cache_config,
173        })
174    }
175
176    /// Create a new client with cache configuration
177    pub async fn new_with_cache(config: Config, cache_config: CacheConfig) -> CommonResult<Self> {
178        let mut client = Self::new(config).await?;
179        client.cache_config = Some(cache_config);
180        Ok(client)
181    }
182
183    /// Get cache configuration
184    pub fn get_cache_config(&self) -> Option<&CacheConfig> {
185        self.cache_config.as_ref()
186    }
187
188    /// Set cache configuration
189    pub fn set_cache_config(&mut self, cache_config: CacheConfig) {
190        self.cache_config = Some(cache_config);
191    }
192
193    /// List cached models
194    pub fn list_cached_models(&self) -> CommonResult<CacheStats> {
195        let cache_config = self.cache_config.as_ref().ok_or_else(|| {
196            modelexpress_common::Error::Server("Cache not configured".to_string())
197        })?;
198
199        cache_config.get_cache_stats().map_err(|e| {
200            modelexpress_common::Error::Server(format!("Failed to get cache stats: {e}")).into()
201        })
202    }
203
204    /// Clear specific model from cache for a given provider.
205    pub fn clear_cached_model(
206        &self,
207        model_name: &str,
208        provider: ModelProvider,
209    ) -> CommonResult<()> {
210        let cache_config = self.cache_config.as_ref().ok_or_else(|| {
211            modelexpress_common::Error::Server("Cache not configured".to_string())
212        })?;
213
214        cache_config.clear_model(model_name, provider).map_err(|e| {
215            modelexpress_common::Error::Server(format!("Failed to clear model: {e}")).into()
216        })
217    }
218
219    /// Clear entire cache
220    pub fn clear_all_cached_models(&self) -> CommonResult<()> {
221        let cache_config = self.cache_config.as_ref().ok_or_else(|| {
222            modelexpress_common::Error::Server("Cache not configured".to_string())
223        })?;
224
225        cache_config.clear_all().map_err(|e| {
226            modelexpress_common::Error::Server(format!("Failed to clear cache: {e}")).into()
227        })
228    }
229
230    /// Get the cache directory path for the requested provider, using the
231    /// provider's environment overrides first and then falling back to the
232    /// client's configured cache root.
233    fn get_cache_dir(&self, provider: ModelProvider) -> PathBuf {
234        use std::env;
235        if provider == ModelProvider::HuggingFace
236            && let Ok(cache_path) = env::var("HF_HUB_CACHE")
237        {
238            return PathBuf::from(cache_path);
239        }
240
241        if let Some(cache_config) = self.cache_config.as_ref() {
242            return cache_config.local_path.clone();
243        }
244
245        CacheConfig::discover()
246            .map(|config| config.local_path)
247            .unwrap_or_else(|_| CacheConfig::default().local_path)
248    }
249
250    pub async fn get_model_path(
251        &self,
252        model_name: &str,
253        provider: ModelProvider,
254    ) -> anyhow::Result<PathBuf> {
255        let cache_dir = self.get_cache_dir(provider);
256        let model_path = download::get_provider(provider)
257            .get_model_path(model_name, cache_dir)
258            .await
259            .map_err(|e| anyhow::anyhow!(format!("Failed to get model path: {e}")))?;
260
261        debug!("Found model path at {:?}", model_path);
262        Ok(model_path)
263    }
264
265    /// Pre-download model to cache
266    /// When shared_storage is disabled, files will be streamed from server to client.
267    pub async fn preload_model_to_cache(
268        &mut self,
269        model_name: &str,
270        provider: ModelProvider,
271        ignore_weights: bool,
272    ) -> CommonResult<()> {
273        info!("Pre-loading model {} to cache", model_name);
274
275        // First try to download via server
276        match self
277            .request_model_with_provider(model_name, provider, ignore_weights)
278            .await
279        {
280            Ok(()) => {
281                info!("Model {} pre-loaded successfully via server", model_name);
282
283                // Check if we need to stream files from server (no shared storage)
284                let needs_streaming = self
285                    .cache_config
286                    .as_ref()
287                    .is_some_and(|c| !c.shared_storage);
288
289                if needs_streaming {
290                    info!(
291                        "Shared storage disabled, streaming files from server for model {}",
292                        model_name
293                    );
294                    self.stream_model_files_from_server(model_name, provider)
295                        .await?;
296                }
297
298                Ok(())
299            }
300            Err(e) => {
301                // Fallback to direct download
302                info!(
303                    "Server unavailable, pre-loading model {} directly. Error: {}",
304                    model_name, e
305                );
306                Self::download_model_directly(model_name, provider, ignore_weights).await
307            }
308        }
309    }
310
311    /// Get the server health status
312    pub async fn health_check(&mut self) -> CommonResult<Status> {
313        let request = tonic::Request::new(HealthRequest {});
314
315        let response = self.health_client.get_health(request).await?;
316        let health_response = response.into_inner();
317
318        Ok(health_response.into())
319    }
320
321    /// Send a request to the server
322    pub async fn send_request<T: serde::de::DeserializeOwned>(
323        &mut self,
324        action: impl Into<String>,
325        payload: Option<HashMap<String, serde_json::Value>>,
326    ) -> CommonResult<T> {
327        let request_id = Uuid::new_v4().to_string();
328
329        let payload_bytes = if let Some(payload) = payload {
330            Some(
331                serde_json::to_vec(&payload)
332                    .map_err(|e| modelexpress_common::Error::Serialization(e.to_string()))?,
333            )
334        } else {
335            None
336        };
337
338        let grpc_request = tonic::Request::new(ApiRequest {
339            id: request_id,
340            action: action.into(),
341            payload: payload_bytes,
342        });
343
344        let response = self.api_client.send_request(grpc_request).await?;
345        let api_response = response.into_inner();
346
347        if !api_response.success {
348            return Err(modelexpress_common::Error::Server(
349                api_response
350                    .error
351                    .unwrap_or_else(|| "Unknown server error".to_string()),
352            )
353            .into());
354        }
355
356        let data_bytes = api_response.data.ok_or_else(|| {
357            modelexpress_common::Error::Server("Server returned success but no data".to_string())
358        })?;
359
360        let data: T = serde_json::from_slice(&data_bytes)
361            .map_err(|e| modelexpress_common::Error::Serialization(e.to_string()))?;
362
363        Ok(data)
364    }
365
366    /// Request a model from the server with a specific provider and automatic fallback
367    /// This function will first try to use the server for streaming downloads.
368    /// If the server is unavailable, it will fallback to downloading directly.
369    /// When shared_storage is disabled, files will be streamed from server to client.
370    pub async fn request_model_with_provider_and_fallback(
371        &mut self,
372        model_name: impl Into<String>,
373        provider: ModelProvider,
374        ignore_weights: bool,
375    ) -> CommonResult<()> {
376        let model_name = model_name.into();
377
378        // First try the server-based approach
379        match self
380            .request_model_with_provider(&model_name, provider, ignore_weights)
381            .await
382        {
383            Ok(()) => {
384                info!("Model {} downloaded successfully via server", model_name);
385
386                // Check if we need to stream files from server (no shared storage)
387                let needs_streaming = self
388                    .cache_config
389                    .as_ref()
390                    .is_some_and(|c| !c.shared_storage);
391
392                if needs_streaming {
393                    info!(
394                        "Shared storage disabled, streaming files from server for model {}",
395                        model_name
396                    );
397                    self.stream_model_files_from_server(&model_name, provider)
398                        .await?;
399                }
400
401                Ok(())
402            }
403            Err(e) => {
404                // Check if it's a connection error (server not available)
405                if let modelexpress_common::Error::Transport(_) = *e {
406                    info!(
407                        "Server unavailable, falling back to direct download for model: {}",
408                        model_name
409                    );
410
411                    // Fallback to direct download
412                    let cache_dir = CacheConfig::discover().ok().map(|config| config.local_path);
413                    match download::download_model(&model_name, provider, cache_dir, ignore_weights).await {
414                        Ok(_) => {
415                            info!(
416                                "Model {} downloaded successfully via direct download",
417                                model_name
418                            );
419                            Ok(())
420                        }
421                        Err(download_err) => Err(modelexpress_common::Error::Server(format!(
422                            "Both server and direct download failed. Server error: {e}. Download error: {download_err}"
423                        )).into()),
424                    }
425                } else {
426                    // For other types of errors, don't fallback
427                    Err(e)
428                }
429            }
430        }
431    }
432
433    /// Stream model files from the server to the local cache
434    /// This is used when shared storage is disabled
435    pub async fn stream_model_files_from_server(
436        &mut self,
437        model_name: &str,
438        provider: ModelProvider,
439    ) -> CommonResult<()> {
440        let cache_config = self.cache_config.as_ref().ok_or_else(|| {
441            modelexpress_common::Error::Server(
442                "Cache configuration is required for file streaming. Please ensure cache_config is set.".to_string()
443            )
444        })?;
445
446        let chunk_size = cache_config.transfer_chunk_size as u32;
447        let local_cache_path = cache_config.local_path.clone();
448
449        info!(
450            "Streaming model {} files to {:?} with chunk size {} bytes",
451            model_name, local_cache_path, chunk_size
452        );
453        std::fs::create_dir_all(&local_cache_path).map_err(|e| {
454            modelexpress_common::Error::Io(format!(
455                "Failed to create local cache directory {:?}: {e}",
456                local_cache_path
457            ))
458        })?;
459
460        let grpc_request = tonic::Request::new(ModelFilesRequest {
461            model_name: model_name.to_string(),
462            provider: modelexpress_common::grpc::model::ModelProvider::from(provider) as i32,
463            chunk_size,
464        });
465
466        let mut stream = self
467            .model_client
468            .stream_model_files(grpc_request)
469            .await?
470            .into_inner();
471
472        // Model directory will be set after receiving the first chunk.
473        let mut model_dir: Option<PathBuf> = None;
474        let mut canonical_model_dir: Option<PathBuf> = None;
475
476        let mut current_file: Option<(PathBuf, tokio::fs::File)> = None;
477        let mut files_received: u64 = 0;
478        let mut bytes_received: u64 = 0;
479        let mut saw_chunk = false;
480
481        while let Some(chunk_result) = stream.message().await? {
482            saw_chunk = true;
483            if model_dir.is_none() {
484                let (dir, canonical_dir) = prepare_stream_model_dir(
485                    &local_cache_path,
486                    provider,
487                    model_name,
488                    chunk_result.commit_hash.as_deref(),
489                )?;
490                model_dir = Some(dir);
491                canonical_model_dir = Some(canonical_dir);
492            }
493
494            let model_dir_ref = model_dir.as_ref().ok_or_else(|| {
495                modelexpress_common::Error::Server("Model directory not initialized".to_string())
496            })?;
497            let canonical_model_dir_ref = canonical_model_dir.as_ref().ok_or_else(|| {
498                modelexpress_common::Error::Server(
499                    "Canonical model directory not initialized".to_string(),
500                )
501            })?;
502
503            let relative_path = PathBuf::from(&chunk_result.relative_path);
504
505            if !is_safe_relative_path(&relative_path) {
506                return Err(Box::new(modelexpress_common::Error::Validation(format!(
507                    "Received potentially unsafe file path from server: {:?}",
508                    chunk_result.relative_path
509                ))));
510            }
511
512            let file_path = model_dir_ref.join(&relative_path);
513
514            // Verify that the resolved path is still within model_dir
515            // Create parent directory first if it doesn't exist to enable proper validation
516            if let Some(parent) = file_path.parent() {
517                if !parent.exists() {
518                    std::fs::create_dir_all(parent).map_err(|e| {
519                        modelexpress_common::Error::Io(format!(
520                            "Failed to create parent directory: {e}"
521                        ))
522                    })?;
523                }
524
525                let canonical_parent = parent.canonicalize().map_err(|e| {
526                    modelexpress_common::Error::Io(format!(
527                        "Failed to canonicalize parent directory: {e}"
528                    ))
529                })?;
530
531                if !canonical_parent.starts_with(canonical_model_dir_ref) {
532                    return Err(Box::new(modelexpress_common::Error::Validation(format!(
533                        "Received file path that resolves outside model directory: {:?}",
534                        chunk_result.relative_path
535                    ))));
536                }
537            }
538
539            // If this is a new file (different path or first chunk)
540            let need_new_file = current_file
541                .as_ref()
542                .is_none_or(|(path, _)| path != &file_path);
543
544            if need_new_file {
545                // Close previous file if any
546                if let Some((prev_path, file)) = current_file.take() {
547                    file.sync_all().await.map_err(|e| {
548                        modelexpress_common::Error::Io(format!(
549                            "Failed to sync file to disk {:?}: {e}",
550                            prev_path
551                        ))
552                    })?;
553                    drop(file);
554                    debug!("Finished writing file: {:?}", prev_path);
555                }
556
557                // Create new file (parent directory was already created during validation)
558                let file = create_stream_output_file(&file_path).await?;
559
560                debug!(
561                    "Starting to receive file: {:?} ({} bytes)",
562                    relative_path, chunk_result.total_size
563                );
564                files_received = files_received.saturating_add(1);
565                current_file = Some((file_path.clone(), file));
566            }
567
568            // Write chunk to file
569            if let Some((_, ref mut file)) = current_file {
570                file.write_all(&chunk_result.data).await.map_err(|e| {
571                    modelexpress_common::Error::Io(format!("Failed to write to file: {e}"))
572                })?;
573                bytes_received = bytes_received.saturating_add(chunk_result.data.len() as u64);
574            }
575
576            // Check if we're done with all files
577            if chunk_result.is_last_file && chunk_result.is_last_chunk {
578                break;
579            }
580        }
581
582        if !saw_chunk {
583            return Err(modelexpress_common::Error::Server(format!(
584                "Server streamed no files for model {}",
585                model_name
586            ))
587            .into());
588        }
589
590        // Ensure the last file is properly closed
591        if let Some((path, file)) = current_file.take() {
592            file.sync_all().await.map_err(|e| {
593                modelexpress_common::Error::Io(format!(
594                    "Failed to sync final file to disk {:?}: {e}",
595                    path
596                ))
597            })?;
598            drop(file);
599            debug!("Finished writing final file: {:?}", path);
600        }
601
602        info!(
603            "Streaming complete: received {} files ({} bytes) for model {}",
604            files_received, bytes_received, model_name
605        );
606
607        Ok(())
608    }
609
610    /// Request a model from the server with a specific provider
611    /// This function will wait until the model is downloaded using streaming updates
612    pub async fn request_model_with_provider(
613        &mut self,
614        model_name: impl Into<String>,
615        provider: ModelProvider,
616        ignore_weights: bool,
617    ) -> CommonResult<()> {
618        let model_name = model_name.into();
619        info!(
620            "Requesting model: {} from provider: {:?}",
621            model_name, provider
622        );
623
624        let grpc_request = tonic::Request::new(ModelDownloadRequest {
625            model_name: model_name.clone(),
626            provider: modelexpress_common::grpc::model::ModelProvider::from(provider) as i32,
627            ignore_weights,
628        });
629
630        let mut stream = self
631            .model_client
632            .ensure_model_downloaded(grpc_request)
633            .await?
634            .into_inner();
635
636        // Process streaming updates until the download is complete
637        while let Some(update_result) = stream.message().await? {
638            let status: ModelStatus =
639                modelexpress_common::grpc::model::ModelStatus::try_from(update_result.status)
640                    .unwrap_or(modelexpress_common::grpc::model::ModelStatus::Error)
641                    .into();
642
643            // Log progress messages if available
644            if let Some(message) = &update_result.message {
645                info!("Model {}: {}", model_name, message);
646            } else {
647                info!("Model {} status: {:?}", model_name, status);
648            }
649
650            // Check if we're done
651            match status {
652                ModelStatus::DOWNLOADED => {
653                    info!("Model {} is now available", model_name);
654                    return Ok(());
655                }
656                ModelStatus::ERROR => {
657                    let error_message = update_result
658                        .message
659                        .unwrap_or_else(|| "Unknown error occurred".to_string());
660                    return Err(modelexpress_common::Error::Server(format!(
661                        "Model download failed: {error_message}"
662                    ))
663                    .into());
664                }
665                ModelStatus::DOWNLOADING => {
666                    // Continue processing updates
667                    continue;
668                }
669            }
670        }
671
672        // If stream ended without DOWNLOADED status, treat as error
673        Err(modelexpress_common::Error::Server(
674            "Model download stream ended unexpectedly".to_string(),
675        )
676        .into())
677    }
678
679    /// Request a model from the server using the default provider (Hugging Face) with automatic fallback
680    /// This function will first try to use the server, then fallback to direct download if needed
681    pub async fn request_model(
682        &mut self,
683        model_name: impl Into<String>,
684        ignore_weights: bool,
685    ) -> CommonResult<()> {
686        self.request_model_with_provider_and_fallback(
687            model_name,
688            ModelProvider::default(),
689            ignore_weights,
690        )
691        .await
692    }
693
694    /// Request a model from the server only (no fallback)
695    /// This function will wait until the model is downloaded using streaming updates from the server
696    pub async fn request_model_server_only(
697        &mut self,
698        model_name: impl Into<String>,
699        ignore_weights: bool,
700    ) -> CommonResult<()> {
701        self.request_model_with_provider(model_name, ModelProvider::default(), ignore_weights)
702            .await
703    }
704
705    /// Request a model with automatic server fallback, creating client connection only if needed
706    /// This function will try to download via server if possible, otherwise download directly
707    pub async fn request_model_with_smart_fallback(
708        model_name: impl Into<String>,
709        provider: ModelProvider,
710        config: Config,
711        ignore_weights: bool,
712    ) -> CommonResult<()> {
713        let model_name = model_name.into();
714
715        // First try to create a client and use server-based download
716        match Client::new(config.clone()).await {
717            Ok(mut client) => {
718                info!("Server connection established, downloading via server...");
719                client
720                    .request_model_with_provider_and_fallback(&model_name, provider, ignore_weights)
721                    .await
722            }
723            Err(e) => {
724                // If we can't even connect to the server, go straight to direct download
725                info!("Cannot connect to server ({}), downloading directly...", e);
726                Client::download_model_directly(&model_name, provider, ignore_weights).await
727            }
728        }
729    }
730
731    /// Download a model directly without using the server
732    /// This bypasses the server entirely and downloads the model using the specified provider
733    pub async fn download_model_directly(
734        model_name: impl Into<String>,
735        provider: ModelProvider,
736        ignore_weights: bool,
737    ) -> CommonResult<()> {
738        let model_name = model_name.into();
739        info!(
740            "Downloading model {} directly using provider: {:?}",
741            model_name, provider
742        );
743
744        // Try to get cache configuration, but don't fail if not available
745        let cache_dir = CacheConfig::discover().ok().map(|config| config.local_path);
746
747        download::download_model(&model_name, provider, cache_dir, ignore_weights)
748            .await
749            .map_err(|e| {
750                modelexpress_common::Error::Server(format!("Direct download failed: {e}"))
751            })?;
752
753        info!("Model {} downloaded successfully", model_name);
754        Ok(())
755    }
756}
757
758#[cfg(test)]
759#[allow(clippy::expect_used)]
760mod tests {
761    use super::*;
762    use futures::Stream;
763    use modelexpress_common::{
764        cache::CacheConfig,
765        grpc::model::{
766            FileChunk, ModelDownloadRequest, ModelFileList, ModelFilesRequest, ModelStatusUpdate,
767            model_service_server::{ModelService, ModelServiceServer},
768        },
769        test_support::{EnvVarGuard, acquire_env_mutex},
770    };
771    use std::collections::HashMap;
772    #[cfg(unix)]
773    use std::os::unix::fs::symlink;
774    use std::pin::Pin;
775    use tempfile::TempDir;
776    use tonic::{Request, Response, Status, transport::Server};
777
778    struct EmptyFileStreamModelService;
779
780    #[tonic::async_trait]
781    impl ModelService for EmptyFileStreamModelService {
782        type EnsureModelDownloadedStream =
783            Pin<Box<dyn Stream<Item = Result<ModelStatusUpdate, Status>> + Send>>;
784        type StreamModelFilesStream = Pin<Box<dyn Stream<Item = Result<FileChunk, Status>> + Send>>;
785
786        async fn ensure_model_downloaded(
787            &self,
788            _request: Request<ModelDownloadRequest>,
789        ) -> Result<Response<Self::EnsureModelDownloadedStream>, Status> {
790            Err(Status::unimplemented(
791                "ensure_model_downloaded is not used in this test",
792            ))
793        }
794
795        async fn stream_model_files(
796            &self,
797            _request: Request<ModelFilesRequest>,
798        ) -> Result<Response<Self::StreamModelFilesStream>, Status> {
799            Ok(Response::new(Box::pin(futures::stream::empty())))
800        }
801
802        async fn list_model_files(
803            &self,
804            _request: Request<ModelFilesRequest>,
805        ) -> Result<Response<ModelFileList>, Status> {
806            Err(Status::unimplemented(
807                "list_model_files is not used in this test",
808            ))
809        }
810    }
811
812    async fn spawn_model_service(
813        service: impl ModelService + 'static,
814    ) -> (
815        std::net::SocketAddr,
816        tokio::task::JoinHandle<Result<(), tonic::transport::Error>>,
817    ) {
818        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
819            .await
820            .expect("Failed to bind test listener");
821        let addr = listener
822            .local_addr()
823            .expect("Failed to read test listener address");
824        let incoming = futures::stream::unfold(listener, |listener| async {
825            match listener.accept().await {
826                Ok((stream, _)) => Some((Ok::<_, std::io::Error>(stream), listener)),
827                Err(_) => None,
828            }
829        });
830
831        let handle = tokio::spawn(async move {
832            Server::builder()
833                .add_service(ModelServiceServer::new(service))
834                .serve_with_incoming(incoming)
835                .await
836        });
837
838        (addr, handle)
839    }
840
841    fn create_test_client_config() -> ClientConfig {
842        ClientConfig::for_testing("http://test-endpoint:1234")
843    }
844
845    fn create_test_client(cache_config: Option<CacheConfig>) -> Client {
846        let channel = tonic::transport::Endpoint::from_static("http://127.0.0.1:1").connect_lazy();
847
848        Client {
849            health_client: HealthServiceClient::new(channel.clone()),
850            api_client: ApiServiceClient::new(channel.clone()),
851            model_client: ModelServiceClient::new(channel),
852            cache_config,
853        }
854    }
855
856    #[test]
857    fn test_client_config_creation() {
858        let config = create_test_client_config();
859        assert_eq!(config.connection.endpoint, "http://test-endpoint:1234");
860    }
861
862    #[test]
863    fn test_client_config_default_timeout() {
864        let config = create_test_client_config();
865        assert!(config.connection.timeout_secs.is_some());
866    }
867
868    #[test]
869    fn test_client_config_with_timeout() {
870        let mut config = create_test_client_config();
871        config.connection.timeout_secs = Some(60);
872        assert_eq!(config.connection.timeout_secs, Some(60));
873    }
874
875    #[test]
876    fn test_endpoint_override_priority() {
877        // This test verifies that the configuration works correctly
878        let config = ClientConfig::for_testing("http://command-line-endpoint:5678");
879
880        // Test that the config has the correct endpoint
881        assert_eq!(
882            config.connection.endpoint,
883            "http://command-line-endpoint:5678"
884        );
885    }
886
887    #[test]
888    fn test_cache_config_endpoint_override() {
889        // Test that cache config can be created with a specific endpoint
890        let mut cache_config = CacheConfig {
891            local_path: std::path::PathBuf::from("/test/path"),
892            server_endpoint: "http://original-endpoint:1234".to_string(),
893            timeout_secs: None,
894            shared_storage: true,
895            transfer_chunk_size: modelexpress_common::constants::DEFAULT_TRANSFER_CHUNK_SIZE,
896        };
897
898        // Simulate endpoint override
899        cache_config.server_endpoint = "http://override-endpoint:5678".to_string();
900
901        assert_eq!(
902            cache_config.server_endpoint,
903            "http://override-endpoint:5678"
904        );
905    }
906
907    #[tokio::test]
908    async fn test_get_cache_dir_for_hf_prefers_hf_hub_cache() {
909        let env_lock = acquire_env_mutex();
910        let hf_cache_dir = TempDir::new().expect("Failed to create HF cache dir");
911        let configured_cache_dir = TempDir::new().expect("Failed to create configured cache dir");
912        let _hf_cache_guard = EnvVarGuard::set(
913            &env_lock,
914            "HF_HUB_CACHE",
915            hf_cache_dir
916                .path()
917                .to_str()
918                .expect("Expected HF cache path"),
919        );
920
921        let cache_config = CacheConfig {
922            local_path: configured_cache_dir.path().to_path_buf(),
923            server_endpoint: "http://localhost:8001".to_string(),
924            timeout_secs: None,
925            shared_storage: true,
926            transfer_chunk_size: modelexpress_common::constants::DEFAULT_TRANSFER_CHUNK_SIZE,
927        };
928        let client = create_test_client(Some(cache_config));
929
930        assert_eq!(
931            client.get_cache_dir(ModelProvider::HuggingFace),
932            hf_cache_dir.path()
933        );
934    }
935
936    // Note: Most client tests require a running server, so they would be integration tests
937    // These unit tests focus on the configuration and setup logic
938
939    #[tokio::test]
940    async fn test_client_new_with_invalid_endpoint() {
941        let config = ClientConfig::for_testing("invalid-endpoint");
942
943        let result = Client::new(config).await;
944        assert!(result.is_err());
945    }
946
947    #[tokio::test]
948    async fn test_download_model_directly_invalid_model() {
949        // This test may fail if network is available and HF is accessible
950        // In a real test environment, you might want to mock the download function
951        let result = Client::download_model_directly(
952            "definitely-not-a-real-model-name-12345",
953            ModelProvider::HuggingFace,
954            false,
955        )
956        .await;
957
958        // Should fail for a non-existent model
959        assert!(result.is_err());
960    }
961
962    #[test]
963    fn test_model_provider_enum() {
964        let provider = ModelProvider::HuggingFace;
965        assert_eq!(provider, ModelProvider::HuggingFace);
966
967        let default_provider = ModelProvider::default();
968        assert_eq!(default_provider, ModelProvider::HuggingFace);
969    }
970
971    #[test]
972    fn test_serialization_payload_creation() {
973        let mut payload = HashMap::new();
974        payload.insert(
975            "key1".to_string(),
976            serde_json::Value::String("value1".to_string()),
977        );
978        payload.insert(
979            "key2".to_string(),
980            serde_json::Value::Number(serde_json::Number::from(42)),
981        );
982
983        let payload_bytes =
984            serde_json::to_vec(&payload).expect("Serialization should not fail in test");
985        let deserialized: HashMap<String, serde_json::Value> =
986            serde_json::from_slice(&payload_bytes)
987                .expect("Deserialization should not fail in test");
988
989        assert_eq!(payload, deserialized);
990    }
991
992    #[test]
993    fn test_prepare_stream_model_dir_falls_back_to_hf_snapshot_layout() {
994        let temp_dir = TempDir::new().expect("Failed to create temp dir");
995        let cache_root = temp_dir.path();
996
997        let (dir, _) = prepare_stream_model_dir(
998            cache_root,
999            ModelProvider::HuggingFace,
1000            "test/model",
1001            Some("abc123"),
1002        )
1003        .expect("Expected legacy snapshot layout");
1004
1005        assert_eq!(dir, cache_root.join("models--test--model/snapshots/abc123"));
1006    }
1007
1008    #[test]
1009    fn test_is_safe_relative_path_allows_leading_curdir() {
1010        assert!(is_safe_relative_path(std::path::Path::new("./config.json")));
1011        assert!(is_safe_relative_path(std::path::Path::new(
1012            "./nested/config.json"
1013        )));
1014    }
1015
1016    #[test]
1017    fn test_is_safe_relative_path_rejects_empty_and_escape_paths() {
1018        assert!(!is_safe_relative_path(std::path::Path::new("")));
1019        assert!(!is_safe_relative_path(std::path::Path::new(".")));
1020        assert!(!is_safe_relative_path(std::path::Path::new(
1021            "../config.json"
1022        )));
1023        assert!(!is_safe_relative_path(std::path::Path::new("/config.json")));
1024    }
1025
1026    #[test]
1027    fn test_prepare_stream_model_dir_requires_hf_commit_hash() {
1028        let temp_dir = TempDir::new().expect("Failed to create temp dir");
1029        let cache_root = temp_dir.path();
1030
1031        let err =
1032            prepare_stream_model_dir(cache_root, ModelProvider::HuggingFace, "test/model", None)
1033                .expect_err("Expected missing revision to fail");
1034
1035        assert!(
1036            matches!(err.as_ref(), modelexpress_common::Error::Validation(message) if message.contains("missing required revision")),
1037            "Unexpected error: {err}"
1038        );
1039    }
1040
1041    #[test]
1042    fn test_prepare_stream_model_dir_uses_io_error_for_local_fs_failures() {
1043        let temp_dir = TempDir::new().expect("Failed to create temp dir");
1044        let cache_root = temp_dir.path().join("cache-root-file");
1045        std::fs::write(&cache_root, b"not a directory").expect("Failed to create cache root file");
1046
1047        let err = prepare_stream_model_dir(
1048            &cache_root,
1049            ModelProvider::HuggingFace,
1050            "test/model",
1051            Some("abc123"),
1052        )
1053        .expect_err("Expected local filesystem failure");
1054
1055        assert!(
1056            matches!(err.as_ref(), modelexpress_common::Error::Io(message) if message.contains("Failed to create model directory")),
1057            "Unexpected error: {err}"
1058        );
1059    }
1060
1061    #[test]
1062    fn test_prepare_stream_model_dir_rejects_paths_outside_cache_root() {
1063        let temp_dir = TempDir::new().expect("Failed to create temp dir");
1064        let cache_root = temp_dir.path().join("cache");
1065        std::fs::create_dir_all(&cache_root).expect("Failed to create cache root");
1066
1067        let err = prepare_stream_model_dir(
1068            &cache_root,
1069            ModelProvider::HuggingFace,
1070            "test/model",
1071            Some("../../../../outside"),
1072        )
1073        .expect_err("Expected escaped model directory to fail validation");
1074
1075        assert!(
1076            matches!(err.as_ref(), modelexpress_common::Error::Validation(message) if message.contains("outside cache root")),
1077            "Unexpected error: {err}"
1078        );
1079    }
1080
1081    #[cfg(unix)]
1082    #[tokio::test]
1083    async fn test_create_stream_output_file_replaces_symlink_without_following_it() {
1084        let temp_dir = TempDir::new().expect("Failed to create temp dir");
1085        let model_dir = temp_dir.path().join("model");
1086        std::fs::create_dir_all(&model_dir).expect("Failed to create model dir");
1087
1088        let outside_path = temp_dir.path().join("outside.txt");
1089        std::fs::write(&outside_path, b"outside").expect("Failed to write outside file");
1090
1091        let file_path = model_dir.join("config.json");
1092        symlink(&outside_path, &file_path).expect("Failed to create symlink");
1093
1094        let mut file = create_stream_output_file(&file_path)
1095            .await
1096            .expect("Expected symlink output path to be replaced");
1097        file.write_all(b"inside")
1098            .await
1099            .expect("Failed to write replacement file");
1100        file.sync_all()
1101            .await
1102            .expect("Failed to sync replacement file");
1103        drop(file);
1104
1105        assert_eq!(
1106            std::fs::read_to_string(&outside_path).expect("Failed to read outside file"),
1107            "outside"
1108        );
1109        assert_eq!(
1110            std::fs::read_to_string(&file_path).expect("Failed to read replacement file"),
1111            "inside"
1112        );
1113        assert!(
1114            !std::fs::symlink_metadata(&file_path)
1115                .expect("Failed to stat replacement file")
1116                .file_type()
1117                .is_symlink(),
1118            "Expected replacement file to no longer be a symlink"
1119        );
1120    }
1121
1122    #[tokio::test]
1123    async fn test_stream_model_files_from_server_errors_on_empty_stream() {
1124        let cache_dir = TempDir::new().expect("Failed to create temp dir");
1125        let (addr, server_handle) = spawn_model_service(EmptyFileStreamModelService).await;
1126
1127        let mut config = ClientConfig::for_testing(format!("http://{addr}"));
1128        config.cache.local_path = cache_dir.path().to_path_buf();
1129        let mut client = Client::new(config)
1130            .await
1131            .expect("Expected test client to connect");
1132
1133        let result = client
1134            .stream_model_files_from_server("test/model", ModelProvider::HuggingFace)
1135            .await;
1136
1137        server_handle.abort();
1138        let _ = server_handle.await;
1139
1140        let err = result.expect_err("Expected empty stream to fail");
1141        assert!(
1142            err.to_string()
1143                .contains("Server streamed no files for model test/model"),
1144            "Unexpected error: {err}"
1145        );
1146    }
1147
1148    #[tokio::test]
1149    async fn test_request_model_with_smart_fallback_no_server() {
1150        // Test the smart fallback when server is not available
1151        let config = Config::for_testing("http://127.0.0.1:99999"); // Invalid port
1152
1153        let result = Client::request_model_with_smart_fallback(
1154            "definitely-not-a-real-model-name-12345",
1155            ModelProvider::HuggingFace,
1156            config,
1157            false,
1158        )
1159        .await;
1160
1161        // Should fail because the model doesn't exist, but we should get past the connection attempt
1162        assert!(result.is_err());
1163        // The error should indicate it tried to connect to the server but failed
1164        let error_msg = result.expect_err("Result should be an error").to_string();
1165        assert!(
1166            error_msg.contains("Direct download failed")
1167                || error_msg.contains("Cannot connect")
1168                || error_msg.contains("gRPC error")
1169                || error_msg.contains("h2 protocol error")
1170                || error_msg.contains("connection")
1171        );
1172    }
1173}
1174
1175#[cfg(test)]
1176#[allow(clippy::expect_used)]
1177mod integration_tests {
1178    use super::*;
1179    use modelexpress_common::constants;
1180    use std::time::Duration;
1181    use tokio::time::timeout;
1182
1183    // These tests require a running server and are more appropriate as integration tests
1184    // They are included here but should be run separately from unit tests
1185
1186    async fn wait_for_server_startup() {
1187        // Give the server time to start up in CI environments
1188        tokio::time::sleep(Duration::from_millis(100)).await;
1189    }
1190
1191    async fn try_create_client() -> Option<Client> {
1192        let config =
1193            Config::for_testing(format!("http://127.0.0.1:{}", constants::DEFAULT_GRPC_PORT));
1194
1195        match timeout(Duration::from_secs(2), Client::new(config)).await {
1196            Ok(Ok(client)) => Some(client),
1197            _ => None,
1198        }
1199    }
1200
1201    #[tokio::test]
1202    #[ignore = "Ignore by default since it requires a running server"]
1203    async fn test_integration_health_check() {
1204        wait_for_server_startup().await;
1205
1206        if let Some(mut client) = try_create_client().await {
1207            let result = client.health_check().await;
1208            assert!(result.is_ok());
1209
1210            let status = result.expect("Health check should succeed");
1211            assert!(!status.version.is_empty());
1212            assert_eq!(status.status, "ok");
1213            // uptime is u64, so it's always >= 0, just check it exists
1214            let _uptime = status.uptime;
1215        } else {
1216            info!("Skipping integration test - server not available");
1217        }
1218    }
1219
1220    #[tokio::test]
1221    #[ignore = "Ignore by default since it requires a running server"]
1222    async fn test_integration_ping_request() {
1223        wait_for_server_startup().await;
1224
1225        if let Some(mut client) = try_create_client().await {
1226            let result: Result<serde_json::Value, _> = client.send_request("ping", None).await;
1227            assert!(result.is_ok());
1228
1229            let response = result.expect("Ping request should succeed");
1230            assert_eq!(response["message"], "pong");
1231        } else {
1232            info!("Skipping integration test - server not available");
1233        }
1234    }
1235
1236    #[tokio::test]
1237    #[ignore = "Ignore by default since it requires a running server and network access"]
1238    async fn test_integration_model_request_tiny_model() {
1239        wait_for_server_startup().await;
1240
1241        if let Some(mut client) = try_create_client().await {
1242            // Use a very small model for testing to avoid long download times
1243            // Note: This test might still take time and requires network access
1244            let result = client
1245                .request_model_server_only("sentence-transformers/all-MiniLM-L6-v2", false)
1246                .await;
1247
1248            // We don't assert success here because it depends on network availability
1249            // In a real integration test environment, you might use a mock model
1250            match result {
1251                Ok(()) => info!("Model download successful"),
1252                Err(e) => warn!("Model download failed (expected in unit test env): {e}"),
1253            }
1254        } else {
1255            info!("Skipping integration test - server not available");
1256        }
1257    }
1258}