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