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, 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            DeleteModelRequest, ModelDownloadRequest, ModelFilesRequest,
14            ModelProvider as GrpcModelProvider, model_service_client::ModelServiceClient,
15        },
16    },
17    models::{ModelStatus, Status},
18    providers::ModelDownloadOutcome,
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
31pub mod auth;
32
33// Re-export for public use
34pub use modelexpress_common::client_config::{ClientArgs, ClientConfig};
35pub use modelexpress_common::models::ModelProvider;
36
37use auth::{AuthInterceptor, TokenProvider};
38use std::sync::Arc;
39use tonic::service::interceptor::InterceptedService;
40
41type AuthChannel = InterceptedService<Channel, AuthInterceptor>;
42
43/// What a completed model request resolved to.
44///
45/// Callers that need the frontend and the workers to agree on one commit use
46/// `resolved_revision`; it is `None` only for providers with no revision concept.
47/// `path` is the local snapshot directory, and is `None` when the client has no cache
48/// configured to resolve it against.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct ModelDownloadResult {
51    pub path: Option<PathBuf>,
52    pub resolved_revision: Option<String>,
53}
54
55impl From<ModelDownloadOutcome> for ModelDownloadResult {
56    /// A direct provider download always knows where it put the files.
57    fn from(outcome: ModelDownloadOutcome) -> Self {
58        Self {
59            path: Some(outcome.path),
60            resolved_revision: outcome.resolved_revision,
61        }
62    }
63}
64
65/// The main client for interacting with the `modelexpress_server` via gRPC
66pub struct Client {
67    health_client: HealthServiceClient<AuthChannel>,
68    api_client: ApiServiceClient<AuthChannel>,
69    model_client: ModelServiceClient<AuthChannel>,
70    cache_config: Option<CacheConfig>,
71}
72
73fn is_safe_relative_path(path: &std::path::Path) -> bool {
74    path.components()
75        .any(|component| matches!(component, Component::Normal(_)))
76        && !path.components().any(|component| {
77            matches!(
78                component,
79                Component::ParentDir | Component::RootDir | Component::Prefix(_)
80            )
81        })
82}
83
84fn prepare_stream_model_dir(
85    local_cache_path: &std::path::Path,
86    provider: ModelProvider,
87    model_name: &str,
88    commit_hash: Option<&str>,
89) -> CommonResult<(PathBuf, PathBuf)> {
90    if provider == ModelProvider::HuggingFace && commit_hash.is_none() {
91        return Err(modelexpress_common::Error::Validation(format!(
92            "Failed to resolve model directory: missing required revision for provider {:?}",
93            provider
94        ))
95        .into());
96    }
97
98    let model_dir = resolve_model_path(local_cache_path, provider, model_name, commit_hash)
99        .map_err(|e| {
100            modelexpress_common::Error::Io(format!("Failed to resolve model directory: {e}"))
101        })?;
102
103    std::fs::create_dir_all(&model_dir).map_err(|e| {
104        modelexpress_common::Error::Io(format!("Failed to create model directory: {e}"))
105    })?;
106
107    let canonical_cache_dir = local_cache_path.canonicalize().map_err(|e| {
108        modelexpress_common::Error::Io(format!(
109            "Failed to canonicalize cache directory {:?}: {e}",
110            local_cache_path
111        ))
112    })?;
113    let canonical_model_dir = model_dir.canonicalize().map_err(|e| {
114        modelexpress_common::Error::Io(format!("Failed to canonicalize model directory: {e}"))
115    })?;
116
117    if !canonical_model_dir.starts_with(&canonical_cache_dir) {
118        return Err(modelexpress_common::Error::Validation(format!(
119            "Received model directory that resolves outside cache root: {:?}",
120            model_dir
121        ))
122        .into());
123    }
124
125    Ok((model_dir, canonical_model_dir))
126}
127
128async fn create_stream_output_file(file_path: &std::path::Path) -> CommonResult<tokio::fs::File> {
129    match std::fs::symlink_metadata(file_path) {
130        Ok(metadata) => {
131            if metadata.is_dir() {
132                return Err(modelexpress_common::Error::Validation(format!(
133                    "Refusing to overwrite directory in model directory: {:?}",
134                    file_path
135                ))
136                .into());
137            }
138
139            if !metadata.is_file() && !metadata.file_type().is_symlink() {
140                return Err(modelexpress_common::Error::Validation(format!(
141                    "Refusing to overwrite unsupported file type in model directory: {:?}",
142                    file_path
143                ))
144                .into());
145            }
146
147            // Existing HF snapshot entries may be symlinks into `blobs/`.
148            // Unlink the final path itself so we never follow it when recreating the file.
149            std::fs::remove_file(file_path).map_err(|e| {
150                modelexpress_common::Error::Io(format!(
151                    "Failed to replace existing file {:?}: {e}",
152                    file_path
153                ))
154            })?;
155        }
156        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
157        Err(e) => {
158            return Err(modelexpress_common::Error::Io(format!(
159                "Failed to inspect output file {:?}: {e}",
160                file_path
161            ))
162            .into());
163        }
164    }
165
166    // `create_new` ensures a final-path symlink introduced after the metadata
167    // check is rejected instead of being followed.
168    tokio::fs::OpenOptions::new()
169        .write(true)
170        .create_new(true)
171        .open(file_path)
172        .await
173        .map_err(|e| {
174            modelexpress_common::Error::Io(format!("Failed to create file {:?}: {e}", file_path))
175                .into()
176        })
177}
178
179struct StreamedFileState {
180    path: PathBuf,
181    file: tokio::fs::File,
182    expected_size: u64,
183    bytes_written: u64,
184    saw_last_chunk: bool,
185}
186
187async fn sync_streamed_file(state: StreamedFileState) -> CommonResult<()> {
188    state.file.sync_all().await.map_err(|e| {
189        modelexpress_common::Error::Io(format!("Failed to sync file to disk {:?}: {e}", state.path))
190    })?;
191    drop(state.file);
192    debug!("Finished writing file: {:?}", state.path);
193    Ok(())
194}
195
196impl Client {
197    /// Create a new client with the given configuration
198    pub async fn new(config: Config) -> CommonResult<Self> {
199        let endpoint = &config.connection.endpoint;
200        let timeout = config
201            .connection
202            .timeout_secs
203            .unwrap_or(constants::DEFAULT_TIMEOUT_SECS);
204
205        let channel = tonic::transport::Endpoint::new(endpoint.clone())
206            .map(|endpoint| endpoint.timeout(Duration::from_secs(timeout)))?
207            .connect()
208            .await?;
209
210        let interceptor = AuthInterceptor::new(Arc::new(TokenProvider::from_env()));
211        let health_client =
212            HealthServiceClient::with_interceptor(channel.clone(), interceptor.clone());
213        let api_client = ApiServiceClient::with_interceptor(channel.clone(), interceptor.clone());
214        let model_client = ModelServiceClient::with_interceptor(channel, interceptor);
215
216        // Use the cache config from the client configuration
217        let cache_config = Some(config.cache.clone());
218
219        Ok(Self {
220            health_client,
221            api_client,
222            model_client,
223            cache_config,
224        })
225    }
226
227    /// Create a new client with cache configuration
228    pub async fn new_with_cache(config: Config, cache_config: CacheConfig) -> CommonResult<Self> {
229        let mut client = Self::new(config).await?;
230        client.cache_config = Some(cache_config);
231        Ok(client)
232    }
233
234    /// Delete a model's record from the server-side registry. This complements
235    /// removing the model's local files, so a cleared model does not leave behind a
236    /// stale `DOWNLOADED` record that would satisfy a later download request without
237    /// re-fetching the files.
238    pub async fn delete_model_on_server(
239        &mut self,
240        model_name: &str,
241        provider: ModelProvider,
242    ) -> CommonResult<()> {
243        let request = tonic::Request::new(DeleteModelRequest {
244            model_name: model_name.to_string(),
245            provider: GrpcModelProvider::from(provider) as i32,
246        });
247
248        let response = self.model_client.delete_model(request).await?;
249        let inner = response.into_inner();
250        if inner.success {
251            Ok(())
252        } else {
253            Err(modelexpress_common::Error::Server(
254                inner
255                    .message
256                    .unwrap_or_else(|| "Failed to delete model from registry".to_string()),
257            )
258            .into())
259        }
260    }
261
262    pub async fn get_model_path(
263        &self,
264        model_name: &str,
265        provider: ModelProvider,
266    ) -> anyhow::Result<PathBuf> {
267        let provider = ModelProvider::resolve_provider_for_model_name(model_name, provider);
268        let cache_dir = self
269            .cache_config
270            .as_ref()
271            .map(|config| config.local_path.clone())
272            .unwrap_or_else(|| {
273                CacheConfig::discover()
274                    .map(|config| config.local_path)
275                    .unwrap_or_else(|_| CacheConfig::default().local_path)
276            });
277        let model_path = download::get_provider(provider)
278            .get_model_path(model_name, cache_dir)
279            .await
280            .map_err(|e| anyhow::anyhow!(format!("Failed to get model path: {e}")))?;
281
282        debug!("Found model path at {:?}", model_path);
283        Ok(model_path)
284    }
285
286    /// Get the server health status
287    pub async fn health_check(&mut self) -> CommonResult<Status> {
288        let request = tonic::Request::new(HealthRequest {});
289
290        let response = self.health_client.get_health(request).await?;
291        let health_response = response.into_inner();
292
293        Ok(health_response.into())
294    }
295
296    /// Send a request to the server
297    pub async fn send_request<T: serde::de::DeserializeOwned>(
298        &mut self,
299        action: impl Into<String>,
300        payload: Option<HashMap<String, serde_json::Value>>,
301    ) -> CommonResult<T> {
302        let request_id = Uuid::new_v4().to_string();
303
304        let payload_bytes = if let Some(payload) = payload {
305            Some(
306                serde_json::to_vec(&payload)
307                    .map_err(|e| modelexpress_common::Error::Serialization(e.to_string()))?,
308            )
309        } else {
310            None
311        };
312
313        let grpc_request = tonic::Request::new(ApiRequest {
314            id: request_id,
315            action: action.into(),
316            payload: payload_bytes,
317        });
318
319        let response = self.api_client.send_request(grpc_request).await?;
320        let api_response = response.into_inner();
321
322        if !api_response.success {
323            return Err(modelexpress_common::Error::Server(
324                api_response
325                    .error
326                    .unwrap_or_else(|| "Unknown server error".to_string()),
327            )
328            .into());
329        }
330
331        let data_bytes = api_response.data.ok_or_else(|| {
332            modelexpress_common::Error::Server("Server returned success but no data".to_string())
333        })?;
334
335        let data: T = serde_json::from_slice(&data_bytes)
336            .map_err(|e| modelexpress_common::Error::Serialization(e.to_string()))?;
337
338        Ok(data)
339    }
340
341    /// Stream model files from the server to the local cache.
342    /// This is an internal helper used when shared storage is disabled.
343    ///
344    /// Returns the local snapshot directory the files were installed into.
345    async fn stream_model_files_from_server(
346        &mut self,
347        model_name: &str,
348        provider: ModelProvider,
349        ignore_weights: bool,
350        revision: Option<&str>,
351    ) -> CommonResult<PathBuf> {
352        let cache_config = self.cache_config.as_ref().ok_or_else(|| {
353            modelexpress_common::Error::Server(
354                "Cache configuration is required for file streaming. Please ensure cache_config is set.".to_string()
355            )
356        })?;
357
358        let chunk_size = cache_config.transfer_chunk_size as u32;
359        let local_cache_path = cache_config.local_path.clone();
360
361        info!(
362            "Streaming model {} files to {:?} with chunk size {} bytes",
363            model_name, local_cache_path, chunk_size
364        );
365        std::fs::create_dir_all(&local_cache_path).map_err(|e| {
366            modelexpress_common::Error::Io(format!(
367                "Failed to create local cache directory {:?}: {e}",
368                local_cache_path
369            ))
370        })?;
371
372        let grpc_request = tonic::Request::new(ModelFilesRequest {
373            model_name: model_name.to_string(),
374            provider: modelexpress_common::grpc::model::ModelProvider::from(provider) as i32,
375            chunk_size,
376            file_selector: None,
377            ignore_weights,
378            revision: revision.map(String::from),
379        });
380
381        let mut stream = self
382            .model_client
383            .stream_model_files(grpc_request)
384            .await?
385            .into_inner();
386
387        // Model directory will be set after receiving the first chunk.
388        let mut model_dir: Option<PathBuf> = None;
389        let mut canonical_model_dir: Option<PathBuf> = None;
390
391        let mut current_file: Option<StreamedFileState> = None;
392        let mut files_received: u64 = 0;
393        let mut bytes_received: u64 = 0;
394        let mut saw_chunk = false;
395        let mut saw_final_chunk = false;
396
397        while let Some(chunk_result) = stream.message().await? {
398            if saw_final_chunk {
399                return Err(modelexpress_common::Error::Validation(format!(
400                    "Received extra file chunk after the final stream marker for model {}",
401                    model_name
402                ))
403                .into());
404            }
405
406            saw_chunk = true;
407            if model_dir.is_none() {
408                let (dir, canonical_dir) = prepare_stream_model_dir(
409                    &local_cache_path,
410                    provider,
411                    model_name,
412                    chunk_result.commit_hash.as_deref(),
413                )?;
414                model_dir = Some(dir);
415                canonical_model_dir = Some(canonical_dir);
416            }
417
418            let model_dir_ref = model_dir.as_ref().ok_or_else(|| {
419                modelexpress_common::Error::Server("Model directory not initialized".to_string())
420            })?;
421            let canonical_model_dir_ref = canonical_model_dir.as_ref().ok_or_else(|| {
422                modelexpress_common::Error::Server(
423                    "Canonical model directory not initialized".to_string(),
424                )
425            })?;
426
427            let relative_path = PathBuf::from(&chunk_result.relative_path);
428
429            if !is_safe_relative_path(&relative_path) {
430                return Err(Box::new(modelexpress_common::Error::Validation(format!(
431                    "Received potentially unsafe file path from server: {:?}",
432                    chunk_result.relative_path
433                ))));
434            }
435
436            let file_path = model_dir_ref.join(&relative_path);
437
438            if chunk_result.offset > chunk_result.total_size {
439                return Err(modelexpress_common::Error::Validation(format!(
440                    "Received chunk with offset {} beyond total size {} for file {:?}",
441                    chunk_result.offset, chunk_result.total_size, chunk_result.relative_path
442                ))
443                .into());
444            }
445
446            // Verify that the resolved path is still within model_dir
447            // Create parent directory first if it doesn't exist to enable proper validation
448            if let Some(parent) = file_path.parent() {
449                if !parent.exists() {
450                    std::fs::create_dir_all(parent).map_err(|e| {
451                        modelexpress_common::Error::Io(format!(
452                            "Failed to create parent directory: {e}"
453                        ))
454                    })?;
455                }
456
457                let canonical_parent = parent.canonicalize().map_err(|e| {
458                    modelexpress_common::Error::Io(format!(
459                        "Failed to canonicalize parent directory: {e}"
460                    ))
461                })?;
462
463                if !canonical_parent.starts_with(canonical_model_dir_ref) {
464                    return Err(Box::new(modelexpress_common::Error::Validation(format!(
465                        "Received file path that resolves outside model directory: {:?}",
466                        chunk_result.relative_path
467                    ))));
468                }
469            }
470
471            // If this is a new file (different path or first chunk)
472            let need_new_file = current_file
473                .as_ref()
474                .is_none_or(|state| state.path != file_path);
475
476            if need_new_file {
477                // Close previous file if any
478                if let Some(previous_file) = current_file.take() {
479                    if !previous_file.saw_last_chunk
480                        || previous_file.bytes_written != previous_file.expected_size
481                    {
482                        return Err(modelexpress_common::Error::Validation(format!(
483                            "Received new file {:?} before previous file {:?} completed",
484                            chunk_result.relative_path, previous_file.path
485                        ))
486                        .into());
487                    }
488                    sync_streamed_file(previous_file).await?;
489                }
490
491                // Create new file (parent directory was already created during validation)
492                if chunk_result.offset != 0 {
493                    return Err(modelexpress_common::Error::Validation(format!(
494                        "Received first chunk for file {:?} with non-zero offset {}",
495                        chunk_result.relative_path, chunk_result.offset
496                    ))
497                    .into());
498                }
499                let file = create_stream_output_file(&file_path).await?;
500
501                debug!(
502                    "Starting to receive file: {:?} ({} bytes)",
503                    relative_path, chunk_result.total_size
504                );
505                files_received = files_received.saturating_add(1);
506                current_file = Some(StreamedFileState {
507                    path: file_path.clone(),
508                    file,
509                    expected_size: chunk_result.total_size,
510                    bytes_written: 0,
511                    saw_last_chunk: false,
512                });
513            }
514
515            // Write chunk to file
516            if let Some(ref mut state) = current_file {
517                if state.saw_last_chunk {
518                    return Err(modelexpress_common::Error::Validation(format!(
519                        "Received extra chunk for completed file {:?}",
520                        chunk_result.relative_path
521                    ))
522                    .into());
523                }
524                if chunk_result.total_size != state.expected_size {
525                    return Err(modelexpress_common::Error::Validation(format!(
526                        "Received inconsistent total size for file {:?}: expected {}, got {}",
527                        chunk_result.relative_path, state.expected_size, chunk_result.total_size
528                    ))
529                    .into());
530                }
531                if chunk_result.offset != state.bytes_written {
532                    return Err(modelexpress_common::Error::Validation(format!(
533                        "Received unexpected offset {} for file {:?}; expected {}",
534                        chunk_result.offset, chunk_result.relative_path, state.bytes_written
535                    ))
536                    .into());
537                }
538
539                let chunk_len = chunk_result.data.len() as u64;
540                let next_size = state.bytes_written.saturating_add(chunk_len);
541                if next_size > state.expected_size {
542                    return Err(modelexpress_common::Error::Validation(format!(
543                        "Received chunk for file {:?} that exceeds the advertised size {}",
544                        chunk_result.relative_path, state.expected_size
545                    ))
546                    .into());
547                }
548
549                state
550                    .file
551                    .write_all(&chunk_result.data)
552                    .await
553                    .map_err(|e| {
554                        modelexpress_common::Error::Io(format!("Failed to write to file: {e}"))
555                    })?;
556                state.bytes_written = next_size;
557                bytes_received = bytes_received.saturating_add(chunk_len);
558
559                if chunk_result.is_last_chunk {
560                    if state.bytes_written != state.expected_size {
561                        return Err(modelexpress_common::Error::Validation(format!(
562                            "Received final chunk for file {:?} with {} bytes written; expected {}",
563                            chunk_result.relative_path, state.bytes_written, state.expected_size
564                        ))
565                        .into());
566                    }
567                    state.saw_last_chunk = true;
568                } else if state.bytes_written == state.expected_size {
569                    return Err(modelexpress_common::Error::Validation(format!(
570                        "Received non-final chunk that completed file {:?}",
571                        chunk_result.relative_path
572                    ))
573                    .into());
574                }
575            }
576
577            // Check if we're done with all files
578            if chunk_result.is_last_file && chunk_result.is_last_chunk {
579                saw_final_chunk = true;
580            }
581        }
582
583        if !saw_chunk {
584            return Err(modelexpress_common::Error::Server(format!(
585                "Server streamed no files for model {}",
586                model_name
587            ))
588            .into());
589        }
590
591        if !saw_final_chunk {
592            return Err(modelexpress_common::Error::Server(format!(
593                "Server stream ended before the final file marker for model {}",
594                model_name
595            ))
596            .into());
597        }
598
599        // Ensure the last file is properly closed
600        if let Some(final_file) = current_file.take() {
601            if !final_file.saw_last_chunk || final_file.bytes_written != final_file.expected_size {
602                return Err(modelexpress_common::Error::Validation(format!(
603                    "Final streamed file {:?} ended incomplete",
604                    final_file.path
605                ))
606                .into());
607            }
608            sync_streamed_file(final_file).await?;
609        }
610
611        info!(
612            "Streaming complete: received {} files ({} bytes) for model {}",
613            files_received, bytes_received, model_name
614        );
615
616        model_dir.ok_or_else(|| {
617            modelexpress_common::Error::Server(format!(
618                "Server streamed no model directory for model {model_name}"
619            ))
620            .into()
621        })
622    }
623
624    /// Request a model on the server at its default revision.
625    pub async fn request_model_on_server(
626        &mut self,
627        model_name: impl Into<String>,
628        provider: ModelProvider,
629        ignore_weights: bool,
630    ) -> CommonResult<()> {
631        self.request_model_on_server_revision(model_name, provider, ignore_weights, None)
632            .await
633            .map(|_| ())
634    }
635
636    /// Request a model on the server at a specific branch, tag, or commit SHA.
637    ///
638    /// Returns the immutable revision the server resolved the request to, or `None` for
639    /// providers that do not expose revisions.
640    pub async fn request_model_on_server_revision(
641        &mut self,
642        model_name: impl Into<String>,
643        provider: ModelProvider,
644        ignore_weights: bool,
645        revision: Option<&str>,
646    ) -> CommonResult<Option<String>> {
647        let model_name = model_name.into();
648        let provider = ModelProvider::resolve_provider_for_model_name(&model_name, provider);
649        match revision {
650            Some(revision) => info!(
651                "Requesting model: {} at revision {} from provider: {:?}",
652                model_name, revision, provider
653            ),
654            None => info!(
655                "Requesting model: {} from provider: {:?}",
656                model_name, provider
657            ),
658        }
659
660        let grpc_request = tonic::Request::new(ModelDownloadRequest {
661            model_name: model_name.clone(),
662            provider: modelexpress_common::grpc::model::ModelProvider::from(provider) as i32,
663            ignore_weights,
664            revision: revision.map(String::from),
665        });
666
667        let mut stream = self
668            .model_client
669            .ensure_model_downloaded(grpc_request)
670            .await?
671            .into_inner();
672
673        // The resolved revision is reported on every update; hold on to the last one
674        // seen so it is still available if the terminal update omits it.
675        let mut resolved_revision: Option<String> = None;
676
677        // Process streaming updates until the download is complete
678        while let Some(update_result) = stream.message().await? {
679            let status: ModelStatus =
680                modelexpress_common::grpc::model::ModelStatus::try_from(update_result.status)
681                    .unwrap_or(modelexpress_common::grpc::model::ModelStatus::Error)
682                    .into();
683
684            if update_result.resolved_revision.is_some() {
685                resolved_revision = update_result.resolved_revision.clone();
686            }
687
688            // Log progress messages if available
689            if let Some(message) = &update_result.message {
690                info!("Model {}: {}", model_name, message);
691            } else {
692                info!("Model {} status: {:?}", model_name, status);
693            }
694
695            // Check if we're done
696            match status {
697                ModelStatus::DOWNLOADED => {
698                    info!("Model {} is now available", model_name);
699                    return Ok(resolved_revision);
700                }
701                ModelStatus::ERROR => {
702                    let error_message = update_result
703                        .message
704                        .unwrap_or_else(|| "Unknown error occurred".to_string());
705                    return Err(modelexpress_common::Error::Server(format!(
706                        "Model download failed: {error_message}"
707                    ))
708                    .into());
709                }
710                ModelStatus::DOWNLOADING => {
711                    // Continue processing updates
712                    continue;
713                }
714            }
715        }
716
717        // If stream ended without DOWNLOADED status, treat as error
718        Err(modelexpress_common::Error::Server(
719            "Model download stream ended unexpectedly".to_string(),
720        )
721        .into())
722    }
723
724    /// Request a model through the client, using the server as the source of truth.
725    /// When shared_storage is disabled, files will be streamed from server to client.
726    ///
727    pub async fn request_model(
728        &mut self,
729        model_name: impl Into<String>,
730        provider: ModelProvider,
731        ignore_weights: bool,
732    ) -> CommonResult<()> {
733        self.request_model_revision(model_name, provider, ignore_weights, None)
734            .await
735            .map(|_| ())
736    }
737
738    /// Request a model at a specific branch, tag, or commit SHA, reporting the snapshot
739    /// path and the immutable revision the request resolved to.
740    ///
741    /// When shared_storage is disabled, files are streamed from the server into the
742    /// local cache under that resolved revision.
743    pub async fn request_model_revision(
744        &mut self,
745        model_name: impl Into<String>,
746        provider: ModelProvider,
747        ignore_weights: bool,
748        revision: Option<&str>,
749    ) -> CommonResult<ModelDownloadResult> {
750        let model_name = model_name.into();
751        let provider = ModelProvider::resolve_provider_for_model_name(&model_name, provider);
752
753        let resolved_revision = self
754            .request_model_on_server_revision(&model_name, provider, ignore_weights, revision)
755            .await?;
756
757        info!("Model {} downloaded successfully via server", model_name);
758
759        // Check if we need to stream files from server (no shared storage)
760        let needs_streaming = self
761            .cache_config
762            .as_ref()
763            .is_some_and(|c| !c.shared_storage);
764
765        let path = if needs_streaming {
766            info!(
767                "Shared storage disabled, streaming files from server for model {}",
768                model_name
769            );
770            let dir = self
771                .stream_model_files_from_server(
772                    &model_name,
773                    provider,
774                    ignore_weights,
775                    resolved_revision.as_deref(),
776                )
777                .await?;
778            self.finish_streamed_install(&model_name, provider, revision, &resolved_revision)
779                .await;
780            Some(dir)
781        } else {
782            self.local_snapshot_path(&model_name, provider, resolved_revision.as_deref())
783        };
784
785        Ok(ModelDownloadResult {
786            path,
787            resolved_revision,
788        })
789    }
790
791    /// Complete the provider-specific cache bookkeeping for a snapshot that arrived over
792    /// the file stream instead of through the provider's own downloader — for Hugging
793    /// Face, the `refs/<revision>` entry that makes the snapshot resolvable by branch or
794    /// tag name offline.
795    ///
796    /// Failing here does not fail the request: every file is already on disk and
797    /// resolvable by commit SHA, which is what the caller was handed.
798    async fn finish_streamed_install(
799        &self,
800        model_name: &str,
801        provider: ModelProvider,
802        requested_revision: Option<&str>,
803        resolved_revision: &Option<String>,
804    ) {
805        let (Some(cache_config), Some(commit)) = (self.cache_config.as_ref(), resolved_revision)
806        else {
807            return;
808        };
809
810        if let Err(e) = download::get_provider(provider)
811            .record_local_revision(
812                model_name,
813                &cache_config.local_path,
814                requested_revision,
815                commit,
816            )
817            .await
818        {
819            debug!("Could not record the revision of {model_name} locally: {e}");
820        }
821    }
822
823    /// Best-effort local path of a downloaded snapshot. `None` when the cache layout
824    /// cannot be resolved or the directory is not present locally, which is the normal
825    /// case for a shared-storage client whose cache is not configured.
826    fn local_snapshot_path(
827        &self,
828        model_name: &str,
829        provider: ModelProvider,
830        revision: Option<&str>,
831    ) -> Option<PathBuf> {
832        let cache_dir = self
833            .cache_config
834            .as_ref()
835            .map(|config| config.local_path.clone())
836            .or_else(|| CacheConfig::discover().map(|config| config.local_path).ok())?;
837
838        resolve_model_path(&cache_dir, provider, model_name, revision)
839            .ok()
840            .filter(|path| path.exists())
841    }
842
843    /// Request a model with automatic server fallback, creating client connection only if needed.
844    /// This function will try to download via server if possible, otherwise download directly
845    /// only if the initial client connection cannot be established.
846    pub async fn request_model_with_smart_fallback(
847        model_name: impl Into<String>,
848        provider: ModelProvider,
849        config: Config,
850        ignore_weights: bool,
851    ) -> CommonResult<()> {
852        Self::request_model_with_smart_fallback_revision(
853            model_name,
854            provider,
855            config,
856            ignore_weights,
857            None,
858        )
859        .await
860        .map(|_| ())
861    }
862
863    /// Revision-aware [`Client::request_model_with_smart_fallback`]. The direct-download
864    /// fallback honours the same revision, so a pinned request cannot silently land on a
865    /// different commit just because the server was unreachable.
866    pub async fn request_model_with_smart_fallback_revision(
867        model_name: impl Into<String>,
868        provider: ModelProvider,
869        config: Config,
870        ignore_weights: bool,
871        revision: Option<&str>,
872    ) -> CommonResult<ModelDownloadResult> {
873        let model_name = model_name.into();
874        let provider = ModelProvider::resolve_provider_for_model_name(&model_name, provider);
875
876        match Client::new(config.clone()).await {
877            Ok(mut client) => {
878                info!("Server connection established, downloading via server...");
879                client
880                    .request_model_revision(&model_name, provider, ignore_weights, revision)
881                    .await
882            }
883            Err(e) => {
884                info!("Cannot connect to server ({}), downloading directly...", e);
885                download::download_model_revision(
886                    &model_name,
887                    provider,
888                    Some(config.cache.local_path.clone()),
889                    ignore_weights,
890                    revision,
891                )
892                .await
893                .map(ModelDownloadResult::from)
894                .map_err(|e| {
895                    modelexpress_common::Error::Generic(format!("Direct download failed: {e}"))
896                        .into()
897                })
898            }
899        }
900    }
901}
902
903#[cfg(test)]
904#[allow(clippy::expect_used)]
905mod tests {
906    use super::*;
907    use futures::Stream;
908    use modelexpress_common::{
909        cache::CacheConfig,
910        grpc::model::{
911            DeleteModelRequest, DeleteModelResponse, FileChunk, ModelDownloadRequest,
912            ModelFileList, ModelFilesRequest, ModelStatusUpdate,
913            model_service_server::{ModelService, ModelServiceServer},
914        },
915    };
916    use std::collections::HashMap;
917    #[cfg(unix)]
918    use std::os::unix::fs::symlink;
919    use std::pin::Pin;
920    use std::sync::{Arc, Mutex};
921    use tempfile::TempDir;
922    use tonic::{Request, Response, Status, transport::Server};
923
924    struct EmptyFileStreamModelService;
925    #[derive(Clone)]
926    struct ChunkSequenceModelService {
927        chunks: Vec<FileChunk>,
928    }
929    struct UnavailableModelService;
930    struct RecordingModelService {
931        seen_model_name: Arc<Mutex<Option<String>>>,
932        seen_provider: Arc<Mutex<Option<i32>>>,
933    }
934    struct ZeroByteGcsMarkerStreamModelService;
935
936    #[tonic::async_trait]
937    impl ModelService for EmptyFileStreamModelService {
938        type EnsureModelDownloadedStream =
939            Pin<Box<dyn Stream<Item = Result<ModelStatusUpdate, Status>> + Send>>;
940        type StreamModelFilesStream = Pin<Box<dyn Stream<Item = Result<FileChunk, Status>> + Send>>;
941
942        async fn ensure_model_downloaded(
943            &self,
944            _request: Request<ModelDownloadRequest>,
945        ) -> Result<Response<Self::EnsureModelDownloadedStream>, Status> {
946            Err(Status::unimplemented(
947                "ensure_model_downloaded is not used in this test",
948            ))
949        }
950
951        async fn stream_model_files(
952            &self,
953            _request: Request<ModelFilesRequest>,
954        ) -> Result<Response<Self::StreamModelFilesStream>, Status> {
955            Ok(Response::new(Box::pin(futures::stream::empty())))
956        }
957
958        async fn list_model_files(
959            &self,
960            _request: Request<ModelFilesRequest>,
961        ) -> Result<Response<ModelFileList>, Status> {
962            Err(Status::unimplemented(
963                "list_model_files is not used in this test",
964            ))
965        }
966
967        async fn delete_model(
968            &self,
969            _request: Request<DeleteModelRequest>,
970        ) -> Result<Response<DeleteModelResponse>, Status> {
971            Err(Status::unimplemented(
972                "delete_model is not used in this test",
973            ))
974        }
975    }
976
977    #[tonic::async_trait]
978    impl ModelService for UnavailableModelService {
979        type EnsureModelDownloadedStream =
980            Pin<Box<dyn Stream<Item = Result<ModelStatusUpdate, Status>> + Send>>;
981        type StreamModelFilesStream = Pin<Box<dyn Stream<Item = Result<FileChunk, Status>> + Send>>;
982
983        async fn ensure_model_downloaded(
984            &self,
985            _request: Request<ModelDownloadRequest>,
986        ) -> Result<Response<Self::EnsureModelDownloadedStream>, Status> {
987            Err(Status::unavailable("test server unavailable"))
988        }
989
990        async fn stream_model_files(
991            &self,
992            _request: Request<ModelFilesRequest>,
993        ) -> Result<Response<Self::StreamModelFilesStream>, Status> {
994            Err(Status::unimplemented(
995                "stream_model_files is not used in this test",
996            ))
997        }
998
999        async fn list_model_files(
1000            &self,
1001            _request: Request<ModelFilesRequest>,
1002        ) -> Result<Response<ModelFileList>, Status> {
1003            Err(Status::unimplemented(
1004                "list_model_files is not used in this test",
1005            ))
1006        }
1007
1008        async fn delete_model(
1009            &self,
1010            _request: Request<DeleteModelRequest>,
1011        ) -> Result<Response<DeleteModelResponse>, Status> {
1012            Err(Status::unimplemented(
1013                "delete_model is not used in this test",
1014            ))
1015        }
1016    }
1017
1018    #[tonic::async_trait]
1019    impl ModelService for ChunkSequenceModelService {
1020        type EnsureModelDownloadedStream =
1021            Pin<Box<dyn Stream<Item = Result<ModelStatusUpdate, Status>> + Send>>;
1022        type StreamModelFilesStream = Pin<Box<dyn Stream<Item = Result<FileChunk, Status>> + Send>>;
1023
1024        async fn ensure_model_downloaded(
1025            &self,
1026            _request: Request<ModelDownloadRequest>,
1027        ) -> Result<Response<Self::EnsureModelDownloadedStream>, Status> {
1028            Err(Status::unimplemented(
1029                "ensure_model_downloaded is not used in this test",
1030            ))
1031        }
1032
1033        async fn stream_model_files(
1034            &self,
1035            _request: Request<ModelFilesRequest>,
1036        ) -> Result<Response<Self::StreamModelFilesStream>, Status> {
1037            Ok(Response::new(Box::pin(futures::stream::iter(
1038                self.chunks.clone().into_iter().map(Ok),
1039            ))))
1040        }
1041
1042        async fn list_model_files(
1043            &self,
1044            _request: Request<ModelFilesRequest>,
1045        ) -> Result<Response<ModelFileList>, Status> {
1046            Err(Status::unimplemented(
1047                "list_model_files is not used in this test",
1048            ))
1049        }
1050
1051        async fn delete_model(
1052            &self,
1053            _request: Request<DeleteModelRequest>,
1054        ) -> Result<Response<DeleteModelResponse>, Status> {
1055            Err(Status::unimplemented(
1056                "delete_model is not used in this test",
1057            ))
1058        }
1059    }
1060
1061    #[tonic::async_trait]
1062    impl ModelService for RecordingModelService {
1063        type EnsureModelDownloadedStream =
1064            Pin<Box<dyn Stream<Item = Result<ModelStatusUpdate, Status>> + Send>>;
1065        type StreamModelFilesStream = Pin<Box<dyn Stream<Item = Result<FileChunk, Status>> + Send>>;
1066
1067        async fn ensure_model_downloaded(
1068            &self,
1069            request: Request<ModelDownloadRequest>,
1070        ) -> Result<Response<Self::EnsureModelDownloadedStream>, Status> {
1071            let request = request.into_inner();
1072            *self
1073                .seen_model_name
1074                .lock()
1075                .expect("Failed to lock seen_model_name") = Some(request.model_name);
1076            *self
1077                .seen_provider
1078                .lock()
1079                .expect("Failed to lock seen_provider") = Some(request.provider);
1080            let update = ModelStatusUpdate {
1081                model_name: "gs://envbucket/dev/bake/qwen/rev123".to_string(),
1082                status: modelexpress_common::grpc::model::ModelStatus::Downloaded as i32,
1083                message: None,
1084                provider: request.provider,
1085                resolved_revision: None,
1086            };
1087            Ok(Response::new(Box::pin(futures::stream::iter(vec![Ok(
1088                update,
1089            )]))))
1090        }
1091
1092        async fn stream_model_files(
1093            &self,
1094            _request: Request<ModelFilesRequest>,
1095        ) -> Result<Response<Self::StreamModelFilesStream>, Status> {
1096            Err(Status::unimplemented(
1097                "stream_model_files is not used in this test",
1098            ))
1099        }
1100
1101        async fn list_model_files(
1102            &self,
1103            _request: Request<ModelFilesRequest>,
1104        ) -> Result<Response<ModelFileList>, Status> {
1105            Err(Status::unimplemented(
1106                "list_model_files is not used in this test",
1107            ))
1108        }
1109
1110        async fn delete_model(
1111            &self,
1112            _request: Request<DeleteModelRequest>,
1113        ) -> Result<Response<DeleteModelResponse>, Status> {
1114            Err(Status::unimplemented(
1115                "delete_model is not used in this test",
1116            ))
1117        }
1118    }
1119
1120    /// Resolves any requested revision to a fixed commit and streams one file from it,
1121    /// recording the revision the client asked the streaming RPC for.
1122    #[derive(Default)]
1123    struct PinnedRevisionModelService {
1124        streamed_revision: Arc<std::sync::Mutex<Option<String>>>,
1125    }
1126
1127    const PINNED_TEST_COMMIT: &str = "abc123def456";
1128
1129    #[tonic::async_trait]
1130    impl ModelService for PinnedRevisionModelService {
1131        type EnsureModelDownloadedStream =
1132            Pin<Box<dyn Stream<Item = Result<ModelStatusUpdate, Status>> + Send>>;
1133        type StreamModelFilesStream = Pin<Box<dyn Stream<Item = Result<FileChunk, Status>> + Send>>;
1134
1135        async fn ensure_model_downloaded(
1136            &self,
1137            request: Request<ModelDownloadRequest>,
1138        ) -> Result<Response<Self::EnsureModelDownloadedStream>, Status> {
1139            let request = request.into_inner();
1140            let update = ModelStatusUpdate {
1141                model_name: request.model_name,
1142                status: modelexpress_common::grpc::model::ModelStatus::Downloaded as i32,
1143                message: None,
1144                provider: request.provider,
1145                resolved_revision: Some(PINNED_TEST_COMMIT.to_string()),
1146            };
1147            Ok(Response::new(Box::pin(futures::stream::iter(vec![Ok(
1148                update,
1149            )]))))
1150        }
1151
1152        async fn stream_model_files(
1153            &self,
1154            request: Request<ModelFilesRequest>,
1155        ) -> Result<Response<Self::StreamModelFilesStream>, Status> {
1156            *self
1157                .streamed_revision
1158                .lock()
1159                .expect("Failed to lock streamed_revision") = request.into_inner().revision;
1160
1161            let data = b"{}".to_vec();
1162            let chunk = FileChunk {
1163                relative_path: "config.json".to_string(),
1164                data: data.clone(),
1165                offset: 0,
1166                total_size: data.len() as u64,
1167                is_last_chunk: true,
1168                is_last_file: true,
1169                commit_hash: Some(PINNED_TEST_COMMIT.to_string()),
1170            };
1171
1172            Ok(Response::new(Box::pin(futures::stream::iter(vec![Ok(
1173                chunk,
1174            )]))))
1175        }
1176
1177        async fn list_model_files(
1178            &self,
1179            _request: Request<ModelFilesRequest>,
1180        ) -> Result<Response<ModelFileList>, Status> {
1181            Err(Status::unimplemented(
1182                "list_model_files is not used in this test",
1183            ))
1184        }
1185
1186        async fn delete_model(
1187            &self,
1188            _request: Request<DeleteModelRequest>,
1189        ) -> Result<Response<DeleteModelResponse>, Status> {
1190            Err(Status::unimplemented(
1191                "delete_model is not used in this test",
1192            ))
1193        }
1194    }
1195
1196    #[tonic::async_trait]
1197    impl ModelService for ZeroByteGcsMarkerStreamModelService {
1198        type EnsureModelDownloadedStream =
1199            Pin<Box<dyn Stream<Item = Result<ModelStatusUpdate, Status>> + Send>>;
1200        type StreamModelFilesStream = Pin<Box<dyn Stream<Item = Result<FileChunk, Status>> + Send>>;
1201
1202        async fn ensure_model_downloaded(
1203            &self,
1204            request: Request<ModelDownloadRequest>,
1205        ) -> Result<Response<Self::EnsureModelDownloadedStream>, Status> {
1206            let request = request.into_inner();
1207            let update = ModelStatusUpdate {
1208                model_name: request.model_name,
1209                status: modelexpress_common::grpc::model::ModelStatus::Downloaded as i32,
1210                message: None,
1211                provider: request.provider,
1212                resolved_revision: request.revision,
1213            };
1214            Ok(Response::new(Box::pin(futures::stream::iter(vec![Ok(
1215                update,
1216            )]))))
1217        }
1218
1219        async fn stream_model_files(
1220            &self,
1221            _request: Request<ModelFilesRequest>,
1222        ) -> Result<Response<Self::StreamModelFilesStream>, Status> {
1223            let chunk = FileChunk {
1224                relative_path: ".mx-model".to_string(),
1225                data: vec![],
1226                offset: 0,
1227                total_size: 0,
1228                is_last_chunk: true,
1229                is_last_file: true,
1230                commit_hash: None,
1231            };
1232
1233            Ok(Response::new(Box::pin(futures::stream::iter(vec![Ok(
1234                chunk,
1235            )]))))
1236        }
1237
1238        async fn list_model_files(
1239            &self,
1240            _request: Request<ModelFilesRequest>,
1241        ) -> Result<Response<ModelFileList>, Status> {
1242            Err(Status::unimplemented(
1243                "list_model_files is not used in this test",
1244            ))
1245        }
1246
1247        async fn delete_model(
1248            &self,
1249            _request: Request<DeleteModelRequest>,
1250        ) -> Result<Response<DeleteModelResponse>, Status> {
1251            Err(Status::unimplemented(
1252                "delete_model is not used in this test",
1253            ))
1254        }
1255    }
1256
1257    async fn spawn_model_service(
1258        service: impl ModelService + 'static,
1259    ) -> (
1260        std::net::SocketAddr,
1261        tokio::task::JoinHandle<Result<(), tonic::transport::Error>>,
1262    ) {
1263        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
1264            .await
1265            .expect("Failed to bind test listener");
1266        let addr = listener
1267            .local_addr()
1268            .expect("Failed to read test listener address");
1269        let incoming = futures::stream::unfold(listener, |listener| async {
1270            match listener.accept().await {
1271                Ok((stream, _)) => Some((Ok::<_, std::io::Error>(stream), listener)),
1272                Err(_) => None,
1273            }
1274        });
1275
1276        let handle = tokio::spawn(async move {
1277            Server::builder()
1278                .add_service(ModelServiceServer::new(service))
1279                .serve_with_incoming(incoming)
1280                .await
1281        });
1282
1283        (addr, handle)
1284    }
1285
1286    fn create_test_client_config() -> ClientConfig {
1287        ClientConfig::for_testing("http://test-endpoint:1234")
1288    }
1289
1290    #[test]
1291    fn test_client_config_creation() {
1292        let config = create_test_client_config();
1293        assert_eq!(config.connection.endpoint, "http://test-endpoint:1234");
1294    }
1295
1296    #[test]
1297    fn test_client_config_default_timeout() {
1298        let config = create_test_client_config();
1299        assert!(config.connection.timeout_secs.is_some());
1300    }
1301
1302    #[test]
1303    fn test_client_config_with_timeout() {
1304        let mut config = create_test_client_config();
1305        config.connection.timeout_secs = Some(60);
1306        assert_eq!(config.connection.timeout_secs, Some(60));
1307    }
1308
1309    #[test]
1310    fn test_endpoint_override_priority() {
1311        // This test verifies that the configuration works correctly
1312        let config = ClientConfig::for_testing("http://command-line-endpoint:5678");
1313
1314        // Test that the config has the correct endpoint
1315        assert_eq!(
1316            config.connection.endpoint,
1317            "http://command-line-endpoint:5678"
1318        );
1319    }
1320
1321    #[test]
1322    fn test_cache_config_endpoint_override() {
1323        // Test that cache config can be created with a specific endpoint
1324        let mut cache_config = CacheConfig {
1325            local_path: std::path::PathBuf::from("/test/path"),
1326            server_endpoint: "http://original-endpoint:1234".to_string(),
1327            timeout_secs: None,
1328            shared_storage: true,
1329            transfer_chunk_size: modelexpress_common::constants::DEFAULT_TRANSFER_CHUNK_SIZE,
1330        };
1331
1332        // Simulate endpoint override
1333        cache_config.server_endpoint = "http://override-endpoint:5678".to_string();
1334
1335        assert_eq!(
1336            cache_config.server_endpoint,
1337            "http://override-endpoint:5678"
1338        );
1339    }
1340
1341    // Note: Most client tests require a running server, so they would be integration tests
1342    // These unit tests focus on the configuration and setup logic
1343
1344    #[tokio::test]
1345    async fn test_client_new_with_invalid_endpoint() {
1346        let config = ClientConfig::for_testing("invalid-endpoint");
1347
1348        let result = Client::new(config).await;
1349        assert!(result.is_err());
1350    }
1351
1352    #[tokio::test]
1353    async fn test_direct_download_invalid_model() {
1354        // This test may fail if network is available and HF is accessible
1355        // In a real test environment, you might want to mock the download function
1356        let result = download::download_model(
1357            "definitely-not-a-real-model-name-12345",
1358            ModelProvider::HuggingFace,
1359            Some(ClientConfig::default().cache.local_path.clone()),
1360            false,
1361        )
1362        .await;
1363
1364        // Should fail for a non-existent model
1365        assert!(result.is_err());
1366    }
1367
1368    #[test]
1369    fn test_model_provider_enum() {
1370        let provider = ModelProvider::HuggingFace;
1371        assert_eq!(provider, ModelProvider::HuggingFace);
1372
1373        let provider = ModelProvider::Gcs;
1374        assert_eq!(provider, ModelProvider::Gcs);
1375
1376        let default_provider = ModelProvider::default();
1377        assert_eq!(default_provider, ModelProvider::HuggingFace);
1378    }
1379
1380    #[test]
1381    fn test_serialization_payload_creation() {
1382        let mut payload = HashMap::new();
1383        payload.insert(
1384            "key1".to_string(),
1385            serde_json::Value::String("value1".to_string()),
1386        );
1387        payload.insert(
1388            "key2".to_string(),
1389            serde_json::Value::Number(serde_json::Number::from(42)),
1390        );
1391
1392        let payload_bytes =
1393            serde_json::to_vec(&payload).expect("Serialization should not fail in test");
1394        let deserialized: HashMap<String, serde_json::Value> =
1395            serde_json::from_slice(&payload_bytes)
1396                .expect("Deserialization should not fail in test");
1397
1398        assert_eq!(payload, deserialized);
1399    }
1400
1401    #[test]
1402    fn test_prepare_stream_model_dir_falls_back_to_hf_snapshot_layout() {
1403        let temp_dir = TempDir::new().expect("Failed to create temp dir");
1404        let cache_root = temp_dir.path();
1405
1406        let (dir, _) = prepare_stream_model_dir(
1407            cache_root,
1408            ModelProvider::HuggingFace,
1409            "test/model",
1410            Some("abc123"),
1411        )
1412        .expect("Expected legacy snapshot layout");
1413
1414        assert_eq!(dir, cache_root.join("models--test--model/snapshots/abc123"));
1415    }
1416
1417    #[test]
1418    fn test_is_safe_relative_path_allows_leading_curdir() {
1419        assert!(is_safe_relative_path(std::path::Path::new("./config.json")));
1420        assert!(is_safe_relative_path(std::path::Path::new(
1421            "./nested/config.json"
1422        )));
1423    }
1424
1425    #[test]
1426    fn test_is_safe_relative_path_rejects_empty_and_escape_paths() {
1427        assert!(!is_safe_relative_path(std::path::Path::new("")));
1428        assert!(!is_safe_relative_path(std::path::Path::new(".")));
1429        assert!(!is_safe_relative_path(std::path::Path::new(
1430            "../config.json"
1431        )));
1432        assert!(!is_safe_relative_path(std::path::Path::new("/config.json")));
1433    }
1434
1435    #[test]
1436    fn test_prepare_stream_model_dir_requires_hf_commit_hash() {
1437        let temp_dir = TempDir::new().expect("Failed to create temp dir");
1438        let cache_root = temp_dir.path();
1439
1440        let err =
1441            prepare_stream_model_dir(cache_root, ModelProvider::HuggingFace, "test/model", None)
1442                .expect_err("Expected missing revision to fail");
1443
1444        assert!(
1445            matches!(err.as_ref(), modelexpress_common::Error::Validation(message) if message.contains("missing required revision")),
1446            "Unexpected error: {err}"
1447        );
1448    }
1449
1450    #[test]
1451    fn test_prepare_stream_model_dir_uses_io_error_for_local_fs_failures() {
1452        let temp_dir = TempDir::new().expect("Failed to create temp dir");
1453        let cache_root = temp_dir.path().join("cache-root-file");
1454        std::fs::write(&cache_root, b"not a directory").expect("Failed to create cache root file");
1455
1456        let err = prepare_stream_model_dir(
1457            &cache_root,
1458            ModelProvider::HuggingFace,
1459            "test/model",
1460            Some("abc123"),
1461        )
1462        .expect_err("Expected local filesystem failure");
1463
1464        assert!(
1465            matches!(err.as_ref(), modelexpress_common::Error::Io(message) if message.contains("Failed to create model directory")),
1466            "Unexpected error: {err}"
1467        );
1468    }
1469
1470    #[test]
1471    fn test_prepare_stream_model_dir_rejects_paths_outside_cache_root() {
1472        let temp_dir = TempDir::new().expect("Failed to create temp dir");
1473        let cache_root = temp_dir.path().join("cache");
1474        std::fs::create_dir_all(&cache_root).expect("Failed to create cache root");
1475
1476        let err = prepare_stream_model_dir(
1477            &cache_root,
1478            ModelProvider::HuggingFace,
1479            "test/model",
1480            Some("../../../../outside"),
1481        )
1482        .expect_err("Expected escaped model directory to fail validation");
1483
1484        assert!(
1485            matches!(err.as_ref(), modelexpress_common::Error::Validation(message) if message.contains("outside cache root")),
1486            "Unexpected error: {err}"
1487        );
1488    }
1489
1490    #[test]
1491    fn test_prepare_stream_model_dir_uses_gcs_layout_without_commit_hash() {
1492        let temp_dir = TempDir::new().expect("Failed to create temp dir");
1493        let cache_root = temp_dir.path();
1494
1495        let (dir, _) = prepare_stream_model_dir(
1496            cache_root,
1497            ModelProvider::Gcs,
1498            "gs://envbucket/dev/bake/qwen/rev123",
1499            None,
1500        )
1501        .expect("Expected GCS cache layout");
1502
1503        assert_eq!(dir, cache_root.join("gcs/envbucket/dev/bake/qwen/rev123"));
1504    }
1505
1506    #[cfg(unix)]
1507    #[tokio::test]
1508    async fn test_create_stream_output_file_replaces_symlink_without_following_it() {
1509        let temp_dir = TempDir::new().expect("Failed to create temp dir");
1510        let model_dir = temp_dir.path().join("model");
1511        std::fs::create_dir_all(&model_dir).expect("Failed to create model dir");
1512
1513        let outside_path = temp_dir.path().join("outside.txt");
1514        std::fs::write(&outside_path, b"outside").expect("Failed to write outside file");
1515
1516        let file_path = model_dir.join("config.json");
1517        symlink(&outside_path, &file_path).expect("Failed to create symlink");
1518
1519        let mut file = create_stream_output_file(&file_path)
1520            .await
1521            .expect("Expected symlink output path to be replaced");
1522        file.write_all(b"inside")
1523            .await
1524            .expect("Failed to write replacement file");
1525        file.sync_all()
1526            .await
1527            .expect("Failed to sync replacement file");
1528        drop(file);
1529
1530        assert_eq!(
1531            std::fs::read_to_string(&outside_path).expect("Failed to read outside file"),
1532            "outside"
1533        );
1534        assert_eq!(
1535            std::fs::read_to_string(&file_path).expect("Failed to read replacement file"),
1536            "inside"
1537        );
1538        assert!(
1539            !std::fs::symlink_metadata(&file_path)
1540                .expect("Failed to stat replacement file")
1541                .file_type()
1542                .is_symlink(),
1543            "Expected replacement file to no longer be a symlink"
1544        );
1545    }
1546
1547    #[tokio::test]
1548    async fn test_stream_model_files_from_server_errors_on_empty_stream() {
1549        let cache_dir = TempDir::new().expect("Failed to create temp dir");
1550        let (addr, server_handle) = spawn_model_service(EmptyFileStreamModelService).await;
1551
1552        let mut config = ClientConfig::for_testing(format!("http://{addr}"));
1553        config.cache.local_path = cache_dir.path().to_path_buf();
1554        let mut client = Client::new(config)
1555            .await
1556            .expect("Expected test client to connect");
1557
1558        let result = client
1559            .stream_model_files_from_server("test/model", ModelProvider::HuggingFace, false, None)
1560            .await;
1561
1562        server_handle.abort();
1563        let _ = server_handle.await;
1564
1565        let err = result.expect_err("Expected empty stream to fail");
1566        assert!(
1567            err.to_string()
1568                .contains("Server streamed no files for model test/model"),
1569            "Unexpected error: {err}"
1570        );
1571    }
1572
1573    #[tokio::test]
1574    async fn test_stream_model_files_from_server_requires_final_marker() {
1575        let cache_dir = TempDir::new().expect("Failed to create temp dir");
1576        let (addr, server_handle) = spawn_model_service(ChunkSequenceModelService {
1577            chunks: vec![FileChunk {
1578                relative_path: "config.json".to_string(),
1579                data: br#"{}"#.to_vec(),
1580                offset: 0,
1581                total_size: 2,
1582                is_last_chunk: true,
1583                is_last_file: false,
1584                commit_hash: Some("abc123".to_string()),
1585            }],
1586        })
1587        .await;
1588
1589        let mut config = ClientConfig::for_testing(format!("http://{addr}"));
1590        config.cache.local_path = cache_dir.path().to_path_buf();
1591        let mut client = Client::new(config)
1592            .await
1593            .expect("Expected test client to connect");
1594
1595        let result = client
1596            .stream_model_files_from_server("test/model", ModelProvider::HuggingFace, false, None)
1597            .await;
1598
1599        server_handle.abort();
1600        let _ = server_handle.await;
1601
1602        let err = result.expect_err("Expected missing final marker to fail");
1603        assert!(
1604            err.to_string().contains("final file marker"),
1605            "Unexpected error: {err}"
1606        );
1607    }
1608
1609    #[tokio::test]
1610    async fn test_stream_model_files_from_server_rejects_unexpected_offsets() {
1611        let cache_dir = TempDir::new().expect("Failed to create temp dir");
1612        let (addr, server_handle) = spawn_model_service(ChunkSequenceModelService {
1613            chunks: vec![
1614                FileChunk {
1615                    relative_path: "config.json".to_string(),
1616                    data: b"{".to_vec(),
1617                    offset: 0,
1618                    total_size: 2,
1619                    is_last_chunk: false,
1620                    is_last_file: true,
1621                    commit_hash: Some("abc123".to_string()),
1622                },
1623                FileChunk {
1624                    relative_path: "config.json".to_string(),
1625                    data: b"}".to_vec(),
1626                    offset: 0,
1627                    total_size: 2,
1628                    is_last_chunk: true,
1629                    is_last_file: true,
1630                    commit_hash: None,
1631                },
1632            ],
1633        })
1634        .await;
1635
1636        let mut config = ClientConfig::for_testing(format!("http://{addr}"));
1637        config.cache.local_path = cache_dir.path().to_path_buf();
1638        let mut client = Client::new(config)
1639            .await
1640            .expect("Expected test client to connect");
1641
1642        let result = client
1643            .stream_model_files_from_server("test/model", ModelProvider::HuggingFace, false, None)
1644            .await;
1645
1646        server_handle.abort();
1647        let _ = server_handle.await;
1648
1649        let err = result.expect_err("Expected invalid offsets to fail");
1650        assert!(
1651            err.to_string().contains("unexpected offset"),
1652            "Unexpected error: {err}"
1653        );
1654    }
1655
1656    #[tokio::test]
1657    async fn test_stream_model_files_from_server_rejects_incomplete_previous_file() {
1658        let cache_dir = TempDir::new().expect("Failed to create temp dir");
1659        let (addr, server_handle) = spawn_model_service(ChunkSequenceModelService {
1660            chunks: vec![
1661                FileChunk {
1662                    relative_path: "config.json".to_string(),
1663                    data: b"{".to_vec(),
1664                    offset: 0,
1665                    total_size: 2,
1666                    is_last_chunk: false,
1667                    is_last_file: false,
1668                    commit_hash: Some("abc123".to_string()),
1669                },
1670                FileChunk {
1671                    relative_path: "tokenizer.json".to_string(),
1672                    data: b"a".to_vec(),
1673                    offset: 0,
1674                    total_size: 1,
1675                    is_last_chunk: true,
1676                    is_last_file: true,
1677                    commit_hash: None,
1678                },
1679            ],
1680        })
1681        .await;
1682
1683        let mut config = ClientConfig::for_testing(format!("http://{addr}"));
1684        config.cache.local_path = cache_dir.path().to_path_buf();
1685        let mut client = Client::new(config)
1686            .await
1687            .expect("Expected test client to connect");
1688
1689        let result = client
1690            .stream_model_files_from_server("test/model", ModelProvider::HuggingFace, false, None)
1691            .await;
1692
1693        server_handle.abort();
1694        let _ = server_handle.await;
1695
1696        let err = result.expect_err("Expected incomplete previous file to fail");
1697        assert!(
1698            err.to_string().contains("before previous file"),
1699            "Unexpected error: {err}"
1700        );
1701    }
1702
1703    #[tokio::test]
1704    async fn test_request_model_streaming_creates_zero_byte_gcs_marker() {
1705        let cache_dir = TempDir::new().expect("Failed to create temp dir");
1706        let (addr, server_handle) = spawn_model_service(ZeroByteGcsMarkerStreamModelService).await;
1707
1708        let mut config = ClientConfig::for_testing(format!("http://{addr}"));
1709        config.cache.local_path = cache_dir.path().to_path_buf();
1710        config.cache.shared_storage = false;
1711
1712        let model_name = "gs://test-bucket/org/model/rev-1";
1713        let mut client = Client::new(config)
1714            .await
1715            .expect("Expected test client to connect");
1716
1717        client
1718            .request_model(model_name, ModelProvider::Gcs, false)
1719            .await
1720            .expect("Expected streamed model request to succeed");
1721
1722        server_handle.abort();
1723        let _ = server_handle.await;
1724
1725        let model_dir = resolve_model_path(cache_dir.path(), ModelProvider::Gcs, model_name, None)
1726            .expect("Expected resolved GCS model dir");
1727        let marker_path = model_dir.join(".mx-model");
1728        assert!(
1729            marker_path.is_file(),
1730            "Expected marker file at {:?}",
1731            marker_path
1732        );
1733        assert_eq!(
1734            std::fs::metadata(&marker_path)
1735                .expect("Expected marker metadata")
1736                .len(),
1737            0
1738        );
1739    }
1740
1741    #[tokio::test]
1742    async fn test_request_model_revision_installs_the_resolved_snapshot() {
1743        let cache_dir = TempDir::new().expect("Failed to create temp dir");
1744        let service = PinnedRevisionModelService::default();
1745        let streamed_revision = service.streamed_revision.clone();
1746        let (addr, server_handle) = spawn_model_service(service).await;
1747
1748        let mut config = ClientConfig::for_testing(format!("http://{addr}"));
1749        config.cache.local_path = cache_dir.path().to_path_buf();
1750        config.cache.shared_storage = false;
1751
1752        let mut client = Client::new(config)
1753            .await
1754            .expect("Expected test client to connect");
1755
1756        let outcome = client
1757            .request_model_revision(
1758                "test/model",
1759                ModelProvider::HuggingFace,
1760                false,
1761                Some("v1.0"),
1762            )
1763            .await
1764            .expect("Expected pinned model request to succeed");
1765
1766        server_handle.abort();
1767        let _ = server_handle.await;
1768
1769        assert_eq!(
1770            outcome.resolved_revision.as_deref(),
1771            Some(PINNED_TEST_COMMIT)
1772        );
1773
1774        // The file stream is requested by resolved commit, not by the tag the caller
1775        // typed, so the snapshot on disk matches the revision that was reported back.
1776        assert_eq!(
1777            streamed_revision
1778                .lock()
1779                .expect("Failed to lock streamed_revision")
1780                .as_deref(),
1781            Some(PINNED_TEST_COMMIT)
1782        );
1783
1784        let expected_dir = cache_dir
1785            .path()
1786            .join("models--test--model/snapshots")
1787            .join(PINNED_TEST_COMMIT);
1788        assert_eq!(outcome.path.as_deref(), Some(expected_dir.as_path()));
1789        assert_eq!(
1790            std::fs::read(expected_dir.join("config.json")).expect("Expected streamed file"),
1791            b"{}"
1792        );
1793
1794        // The installed snapshot has to be a valid Hugging Face cache entry: without the
1795        // ref, huggingface_hub cannot resolve the tag offline even though the files are
1796        // all there.
1797        assert_eq!(
1798            std::fs::read_to_string(cache_dir.path().join("models--test--model/refs/v1.0"))
1799                .expect("Expected the requested revision to be recorded"),
1800            PINNED_TEST_COMMIT
1801        );
1802    }
1803
1804    #[tokio::test]
1805    async fn test_request_model_with_smart_fallback_no_server() {
1806        let config = Config::for_testing("http://127.0.0.1:99999"); // Invalid port
1807
1808        let result = Client::request_model_with_smart_fallback(
1809            "definitely-not-a-real-model-name-12345",
1810            ModelProvider::HuggingFace,
1811            config,
1812            false,
1813        )
1814        .await;
1815
1816        assert!(result.is_err());
1817        let error_msg = result.expect_err("Result should be an error").to_string();
1818        assert!(
1819            error_msg.contains("Direct download failed")
1820                || error_msg.contains("Cannot connect")
1821                || error_msg.contains("gRPC error")
1822                || error_msg.contains("h2 protocol error")
1823                || error_msg.contains("connection")
1824        );
1825    }
1826
1827    #[tokio::test]
1828    async fn test_request_model_with_smart_fallback_does_not_direct_download_midflight() {
1829        let cache_dir = TempDir::new().expect("Failed to create temp dir");
1830        let (addr, server_handle) = spawn_model_service(UnavailableModelService).await;
1831
1832        let mut config = ClientConfig::for_testing(format!("http://{addr}"));
1833        config.cache.local_path = cache_dir.path().to_path_buf();
1834
1835        let result = Client::request_model_with_smart_fallback(
1836            "definitely-not-a-real-model-name-12345",
1837            ModelProvider::HuggingFace,
1838            config,
1839            false,
1840        )
1841        .await;
1842
1843        server_handle.abort();
1844        let _ = server_handle.await;
1845
1846        let err = result.expect_err("Expected server request failure");
1847        let error_msg = err.to_string();
1848        assert!(
1849            !error_msg.contains("Direct download failed"),
1850            "Smart fallback should not switch to direct download after the server path starts: {error_msg}"
1851        );
1852        assert!(
1853            error_msg.contains("gRPC error") || error_msg.contains("unavailable"),
1854            "Expected a server-side availability error, got: {error_msg}"
1855        );
1856    }
1857
1858    #[tokio::test]
1859    #[allow(clippy::await_holding_lock)]
1860    async fn test_request_model_with_smart_fallback_accepts_full_gcs_url() {
1861        let seen_model_name = Arc::new(Mutex::new(None));
1862        let seen_provider = Arc::new(Mutex::new(None));
1863        let (addr, server_handle) = spawn_model_service(RecordingModelService {
1864            seen_model_name: Arc::clone(&seen_model_name),
1865            seen_provider: Arc::clone(&seen_provider),
1866        })
1867        .await;
1868
1869        let mut config = ClientConfig::for_testing(format!("http://{addr}"));
1870        config.cache.shared_storage = true;
1871
1872        Client::request_model_with_smart_fallback(
1873            "gs://envbucket/dev/bake/qwen/rev123",
1874            ModelProvider::Gcs,
1875            config,
1876            false,
1877        )
1878        .await
1879        .expect("Expected smart fallback request to succeed");
1880
1881        server_handle.abort();
1882        let _ = server_handle.await;
1883
1884        assert_eq!(
1885            seen_model_name
1886                .lock()
1887                .expect("Failed to lock seen_model_name")
1888                .clone(),
1889            "gs://envbucket/dev/bake/qwen/rev123".to_string().into()
1890        );
1891        assert_eq!(
1892            *seen_provider.lock().expect("Failed to lock seen_provider"),
1893            Some(modelexpress_common::grpc::model::ModelProvider::Gcs as i32)
1894        );
1895    }
1896
1897    #[tokio::test]
1898    #[allow(clippy::await_holding_lock)]
1899    async fn test_request_model_with_smart_fallback_infers_s3_provider() {
1900        let seen_model_name = Arc::new(Mutex::new(None));
1901        let seen_provider = Arc::new(Mutex::new(None));
1902        let (addr, server_handle) = spawn_model_service(RecordingModelService {
1903            seen_model_name: Arc::clone(&seen_model_name),
1904            seen_provider: Arc::clone(&seen_provider),
1905        })
1906        .await;
1907
1908        let mut config = ClientConfig::for_testing(format!("http://{addr}"));
1909        config.cache.shared_storage = true;
1910
1911        Client::request_model_with_smart_fallback(
1912            "s3://envbucket/dev/bake/qwen/rev123",
1913            ModelProvider::HuggingFace,
1914            config,
1915            true,
1916        )
1917        .await
1918        .expect("Expected smart fallback request to succeed");
1919
1920        server_handle.abort();
1921        let _ = server_handle.await;
1922
1923        assert_eq!(
1924            seen_model_name
1925                .lock()
1926                .expect("Failed to lock seen_model_name")
1927                .clone(),
1928            "s3://envbucket/dev/bake/qwen/rev123".to_string().into()
1929        );
1930        assert_eq!(
1931            *seen_provider.lock().expect("Failed to lock seen_provider"),
1932            Some(modelexpress_common::grpc::model::ModelProvider::S3 as i32)
1933        );
1934    }
1935}
1936
1937#[cfg(test)]
1938#[allow(clippy::expect_used)]
1939mod integration_tests {
1940    use super::*;
1941    use modelexpress_common::constants;
1942    use std::time::Duration;
1943    use tokio::time::timeout;
1944
1945    // These tests require a running server and are more appropriate as integration tests
1946    // They are included here but should be run separately from unit tests
1947
1948    async fn wait_for_server_startup() {
1949        // Give the server time to start up in CI environments
1950        tokio::time::sleep(Duration::from_millis(100)).await;
1951    }
1952
1953    async fn try_create_client() -> Option<Client> {
1954        let config =
1955            Config::for_testing(format!("http://127.0.0.1:{}", constants::DEFAULT_GRPC_PORT));
1956
1957        match timeout(Duration::from_secs(2), Client::new(config)).await {
1958            Ok(Ok(client)) => Some(client),
1959            _ => None,
1960        }
1961    }
1962
1963    #[tokio::test]
1964    #[ignore = "Ignore by default since it requires a running server"]
1965    async fn test_integration_health_check() {
1966        wait_for_server_startup().await;
1967
1968        if let Some(mut client) = try_create_client().await {
1969            let result = client.health_check().await;
1970            assert!(result.is_ok());
1971
1972            let status = result.expect("Health check should succeed");
1973            assert!(!status.version.is_empty());
1974            assert_eq!(status.status, "ok");
1975            // uptime is u64, so it's always >= 0, just check it exists
1976            let _uptime = status.uptime;
1977        } else {
1978            info!("Skipping integration test - server not available");
1979        }
1980    }
1981
1982    #[tokio::test]
1983    #[ignore = "Ignore by default since it requires a running server"]
1984    async fn test_integration_ping_request() {
1985        wait_for_server_startup().await;
1986
1987        if let Some(mut client) = try_create_client().await {
1988            let result: Result<serde_json::Value, _> = client.send_request("ping", None).await;
1989            assert!(result.is_ok());
1990
1991            let response = result.expect("Ping request should succeed");
1992            assert_eq!(response["message"], "pong");
1993        } else {
1994            info!("Skipping integration test - server not available");
1995        }
1996    }
1997
1998    #[tokio::test]
1999    #[ignore = "Ignore by default since it requires a running server and network access"]
2000    async fn test_integration_model_request_tiny_model() {
2001        wait_for_server_startup().await;
2002
2003        if let Some(mut client) = try_create_client().await {
2004            // Use a very small model for testing to avoid long download times
2005            // Note: This test might still take time and requires network access
2006            let result = client
2007                .request_model_on_server(
2008                    "sentence-transformers/all-MiniLM-L6-v2",
2009                    ModelProvider::default(),
2010                    false,
2011                )
2012                .await;
2013
2014            // We don't assert success here because it depends on network availability
2015            // In a real integration test environment, you might use a mock model
2016            match result {
2017                Ok(()) => info!("Model download successful"),
2018                Err(e) => warn!("Model download failed (expected in unit test env): {e}"),
2019            }
2020        } else {
2021            info!("Skipping integration test - server not available");
2022        }
2023    }
2024}