feat(model-server): progression fine du téléchargement du modèle llamacpp (#54)

Stretch B2/F2 de #54, par-dessus le MVP déjà mergé (B1/F1).

Backend : le port de téléchargement HF publie une progression débouncée
(bytes reçus / total, pourcentage) via le stream de statut du serveur
modèle, avec gestion du total inconnu (pas de faux %), du cache hit,
de l'annulation et du timeout.

Frontend : l'overlay plein-cellule de préparation du serveur affiche la
progression réelle (barre, %, octets, source) en mappant le fil de
statut, avec la règle « pas de faux % » quand le total est inconnu.

Tests : application + infrastructure (téléchargement débouncé, cancel,
timeout, cache hit, total inconnu) et vitest (overlay + formatage pur).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 10:37:14 +02:00
parent bd335c3a1c
commit 2183dfd291
15 changed files with 1368 additions and 87 deletions

View File

@ -213,6 +213,7 @@ fn model_server_error_code(err: &ModelServerError) -> &'static str {
ModelServerError::Probe(_) => "probe",
ModelServerError::Process(_) => "process",
ModelServerError::Store(_) => "store",
ModelServerError::Cancelled => "cancelled",
ModelServerError::Timeout => "timeout",
}
}

View File

@ -3,6 +3,7 @@
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use std::time::Instant as StdInstant;
use domain::events::DomainEvent;
use domain::model_server::{
@ -10,7 +11,8 @@ use domain::model_server::{
ModelSource,
};
use domain::ports::{
EventBus, FileSystem, ManagedProcess, ManagedProcessHandle, ModelServerError, ModelServerProbe,
EventBus, FileSystem, ManagedProcess, ManagedProcessHandle, ModelArtifactCancel,
ModelArtifactDownloader, ModelArtifactProgress, ModelServerError, ModelServerProbe,
ModelServerRegistry, ModelServerRuntime, ProcessStatus, ProfileStore, RemotePath,
};
use domain::{LocalModelServerId, StopPolicy};
@ -215,8 +217,10 @@ pub struct EnsureLocalModelServer {
events: Arc<dyn EventBus>,
active: Mutex<HashMap<LocalModelServerId, ActiveServer>>,
inflight: AsyncMutex<HashMap<LocalModelServerId, Arc<InflightEnsure>>>,
download_cancels: Mutex<HashMap<LocalModelServerId, ModelArtifactCancel>>,
readiness: ReadinessPolicy,
hf_download_deadline: Duration,
model_artifact_downloader: Option<Arc<dyn ModelArtifactDownloader>>,
}
impl EnsureLocalModelServer {
@ -240,8 +244,10 @@ impl EnsureLocalModelServer {
events,
active: Mutex::new(HashMap::new()),
inflight: AsyncMutex::new(HashMap::new()),
download_cancels: Mutex::new(HashMap::new()),
readiness: ReadinessPolicy::default(),
hf_download_deadline: DEFAULT_HF_DOWNLOAD_DEADLINE,
model_artifact_downloader: None,
}
}
@ -259,6 +265,16 @@ impl EnsureLocalModelServer {
self
}
/// Enables pre-resolution/download of Hugging Face artifacts.
#[must_use]
pub fn with_model_artifact_downloader(
mut self,
downloader: Arc<dyn ModelArtifactDownloader>,
) -> Self {
self.model_artifact_downloader = Some(downloader);
self
}
/// Ensures the server is reachable.
///
/// # Errors
@ -332,8 +348,13 @@ impl EnsureLocalModelServer {
self.ensure_model_path_accessible(&config).await?;
self.ensure_no_active_port_collision(&config).await?;
let spawn_config = match self.prepare_spawn_config(config.clone()).await {
Ok(config) => config,
Err(err) => return self.fail(config.id, err),
};
self.publish(config.id, ModelServerLifecycleStatus::Starting);
let spec = match self.runtime.build_spawn_spec(&config) {
let spec = match self.runtime.build_spawn_spec(&spawn_config) {
Ok(spec) => spec,
Err(err) => return self.fail(config.id, err),
};
@ -345,16 +366,71 @@ impl EnsureLocalModelServer {
config.id,
ActiveServer {
handle: handle.clone(),
port: config.endpoint.port,
stop_policy: config.stop_policy,
port: spawn_config.endpoint.port,
stop_policy: spawn_config.stop_policy,
},
);
let hf_source = hf_source(&config);
self.wait_for_started_server(&config, &handle, hf_source)
let hf_source = hf_source(&spawn_config);
self.wait_for_started_server(&spawn_config, &handle, hf_source)
.await
}
async fn prepare_spawn_config(
&self,
config: LocalModelServerConfig,
) -> Result<LocalModelServerConfig, ModelServerError> {
let Some(ModelSource::HuggingFace { repo }) = config.model.source.clone() else {
return Ok(config);
};
let Some(downloader) = self.model_artifact_downloader.as_ref() else {
return Ok(config);
};
let cancel = ModelArtifactCancel::new();
self.download_cancels
.lock()
.unwrap()
.insert(config.id, cancel.clone());
let events = Arc::clone(&self.events);
let source = repo.as_str().to_owned();
let server_id = config.id;
let debouncer = Arc::new(Mutex::new(DownloadProgressDebouncer::new(
server_id,
source.clone(),
events,
)));
let progress = {
let debouncer = Arc::clone(&debouncer);
Arc::new(move |progress: ModelArtifactProgress| {
debouncer.lock().unwrap().on_progress(progress);
}) as Arc<dyn Fn(ModelArtifactProgress) + Send + Sync>
};
let result = tokio::time::timeout(
self.hf_download_deadline,
downloader.resolve_hf_model(&repo, progress, cancel.clone()),
)
.await;
self.download_cancels.lock().unwrap().remove(&config.id);
let resolution = match result {
Ok(Ok(resolution)) => resolution,
Ok(Err(err)) => return Err(err),
Err(_) => {
cancel.cancel();
return Err(ModelServerError::Timeout);
}
};
let mut resolved = config;
resolved.model.source = Some(ModelSource::LocalPath {
path: resolution.path,
});
Ok(resolved)
}
async fn wait_for_started_server(
&self,
config: &LocalModelServerConfig,
@ -468,6 +544,9 @@ impl EnsureLocalModelServer {
/// # Errors
/// Returns the first process error after attempting every eligible stop.
pub async fn stop_on_app_exit(&self) -> Result<(), AppError> {
for cancel in self.download_cancels.lock().unwrap().values() {
cancel.cancel();
}
let entries: Vec<(LocalModelServerId, ActiveServer)> = self
.active
.lock()
@ -626,6 +705,109 @@ pub fn model_server_error_code(err: &ModelServerError) -> &'static str {
ModelServerError::Probe(_) => "probe",
ModelServerError::Process(_) => "process",
ModelServerError::Store(_) => "store",
ModelServerError::Cancelled => "cancelled",
ModelServerError::Timeout => "timeout",
}
}
struct DownloadProgressDebouncer {
server_id: LocalModelServerId,
fallback_source: String,
events: Arc<dyn EventBus>,
last_emit: Option<StdInstant>,
last_percent: Option<f32>,
window_start: StdInstant,
window_count: u32,
}
impl DownloadProgressDebouncer {
fn new(
server_id: LocalModelServerId,
fallback_source: String,
events: Arc<dyn EventBus>,
) -> Self {
let now = StdInstant::now();
Self {
server_id,
fallback_source,
events,
last_emit: None,
last_percent: None,
window_start: now,
window_count: 0,
}
}
fn on_progress(&mut self, progress: ModelArtifactProgress) {
let percent = progress_percent(&progress);
let final_progress = is_final_progress(&progress);
if !self.should_publish(percent, final_progress) {
return;
}
self.mark_published(percent);
self.events.publish(DomainEvent::ModelServerStatusChanged {
server_id: self.server_id,
status: ModelServerLifecycleStatus::Downloading {
downloaded_bytes: progress.downloaded_bytes,
total_bytes: progress.total_bytes,
percent,
source: progress
.source
.or_else(|| Some(self.fallback_source.clone())),
},
});
}
fn should_publish(&mut self, percent: Option<f32>, final_progress: bool) -> bool {
let now = StdInstant::now();
if now.duration_since(self.window_start) >= Duration::from_secs(1) {
self.window_start = now;
self.window_count = 0;
}
if self.last_emit.is_none() || final_progress {
return true;
}
if self.window_count >= 10 {
return false;
}
if self
.last_emit
.is_some_and(|last| now.duration_since(last) < Duration::from_millis(100))
{
return false;
}
if self
.last_emit
.is_some_and(|last| now.duration_since(last) >= Duration::from_millis(500))
{
return true;
}
match (self.last_percent, percent) {
(Some(last), Some(current)) => current - last >= 1.0,
(None, Some(_)) => true,
_ => false,
}
}
fn mark_published(&mut self, percent: Option<f32>) {
self.last_emit = Some(StdInstant::now());
self.last_percent = percent.or(self.last_percent);
self.window_count += 1;
}
}
fn progress_percent(progress: &ModelArtifactProgress) -> Option<f32> {
let downloaded = progress.downloaded_bytes?;
let total = progress.total_bytes?;
if total == 0 {
return None;
}
Some((downloaded as f32 / total as f32) * 100.0)
}
fn is_final_progress(progress: &ModelArtifactProgress) -> bool {
matches!(
(progress.downloaded_bytes, progress.total_bytes),
(Some(downloaded), Some(total)) if total > 0 && downloaded == total
)
}