1use 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
33pub 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#[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 fn from(outcome: ModelDownloadOutcome) -> Self {
58 Self {
59 path: Some(outcome.path),
60 resolved_revision: outcome.resolved_revision,
61 }
62 }
63}
64
65pub 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let mut resolved_revision: Option<String> = None;
676
677 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 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 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;
713 }
714 }
715 }
716
717 Err(modelexpress_common::Error::Server(
719 "Model download stream ended unexpectedly".to_string(),
720 )
721 .into())
722 }
723
724 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 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 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 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 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 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 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 #[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 let config = ClientConfig::for_testing("http://command-line-endpoint:5678");
1313
1314 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 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 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 #[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 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 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 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 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"); 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 async fn wait_for_server_startup() {
1949 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 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 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 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}