1use 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
29pub use modelexpress_common::client_config::{ClientArgs, ClientConfig};
31pub use modelexpress_common::models::ModelProvider;
32
33pub 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 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 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 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 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 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 pub fn get_cache_config(&self) -> Option<&CacheConfig> {
185 self.cache_config.as_ref()
186 }
187
188 pub fn set_cache_config(&mut self, cache_config: CacheConfig) {
190 self.cache_config = Some(cache_config);
191 }
192
193 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 Err(e)
428 }
429 }
430 }
431 }
432
433 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 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 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 let need_new_file = current_file
541 .as_ref()
542 .is_none_or(|(path, _)| path != &file_path);
543
544 if need_new_file {
545 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 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 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 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 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 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 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 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 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;
668 }
669 }
670 }
671
672 Err(modelexpress_common::Error::Server(
674 "Model download stream ended unexpectedly".to_string(),
675 )
676 .into())
677 }
678
679 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 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 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 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 info!("Cannot connect to server ({}), downloading directly...", e);
726 Client::download_model_directly(&model_name, provider, ignore_weights).await
727 }
728 }
729 }
730
731 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 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 let config = ClientConfig::for_testing("http://command-line-endpoint:5678");
879
880 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 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 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 #[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 let result = Client::download_model_directly(
952 "definitely-not-a-real-model-name-12345",
953 ModelProvider::HuggingFace,
954 false,
955 )
956 .await;
957
958 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 let config = Config::for_testing("http://127.0.0.1:99999"); 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 assert!(result.is_err());
1163 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 async fn wait_for_server_startup() {
1187 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 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 let result = client
1245 .request_model_server_only("sentence-transformers/all-MiniLM-L6-v2", false)
1246 .await;
1247
1248 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}