feat(model-server): afficher le téléchargement du modèle llamacpp au démarrage (#54)

Ajoute un handle du téléchargement des modèles lors du démarrage de
llamacpp : le domaine et l'application émettent la progression de
téléchargement du modèle, relayée en événement côté app-tauri, et l'UI
l'affiche via un badge de lancement et un overlay de cellule pendant que
le serveur de modèle démarre.

Backend (B1) : progression de téléchargement dans domain/application,
relais d'événement app-tauri, couverture de tests.
Frontend (F1) : modelServerLaunch, badge et overlay LayoutGrid, tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-13 23:14:05 +02:00
parent c67ec4f7bd
commit fe7ed0aa20
12 changed files with 891 additions and 29 deletions

View File

@ -15,6 +15,7 @@ use domain::ports::{
};
use domain::{LocalModelServerId, StopPolicy};
use tokio::sync::{Mutex as AsyncMutex, Notify};
use tokio::time::Instant;
use crate::error::AppError;
@ -161,6 +162,13 @@ impl Default for ReadinessPolicy {
}
}
/// Default upper bound for Hugging Face model download/preparation.
///
/// llama.cpp may download multi-GB models before binding the OpenAI-compatible
/// endpoint. The short readiness window remains for local files; HF sources use
/// this separate deadline while the process is still alive.
pub const DEFAULT_HF_DOWNLOAD_DEADLINE: Duration = Duration::from_secs(30 * 60);
#[derive(Debug, Clone)]
struct ActiveServer {
handle: ManagedProcessHandle,
@ -208,6 +216,7 @@ pub struct EnsureLocalModelServer {
active: Mutex<HashMap<LocalModelServerId, ActiveServer>>,
inflight: AsyncMutex<HashMap<LocalModelServerId, Arc<InflightEnsure>>>,
readiness: ReadinessPolicy,
hf_download_deadline: Duration,
}
impl EnsureLocalModelServer {
@ -232,6 +241,7 @@ impl EnsureLocalModelServer {
active: Mutex::new(HashMap::new()),
inflight: AsyncMutex::new(HashMap::new()),
readiness: ReadinessPolicy::default(),
hf_download_deadline: DEFAULT_HF_DOWNLOAD_DEADLINE,
}
}
@ -242,6 +252,13 @@ impl EnsureLocalModelServer {
self
}
/// Overrides the long Hugging Face download/preparation deadline.
#[must_use]
pub fn with_hf_download_deadline(mut self, deadline: Duration) -> Self {
self.hf_download_deadline = deadline;
self
}
/// Ensures the server is reachable.
///
/// # Errors
@ -333,10 +350,21 @@ impl EnsureLocalModelServer {
},
);
let hf_source = hf_source(&config);
self.wait_for_started_server(&config, &handle, hf_source)
.await
}
async fn wait_for_started_server(
&self,
config: &LocalModelServerConfig,
handle: &ManagedProcessHandle,
hf_source: Option<String>,
) -> Result<EnsureLocalModelServerOutput, AppError> {
for attempt in 0..self.readiness.attempts {
match self.probe.probe(&config.endpoint).await {
Err(err) => {
self.stop_started_server(config.id, &handle).await;
self.stop_started_server(config.id, handle).await;
return self.fail(config.id, err);
}
Ok(ModelServerStatus::ReadyReused | ModelServerStatus::ReadyStarted) => {
@ -345,7 +373,7 @@ impl EnsureLocalModelServer {
ModelServerLifecycleStatus::Ready { reused: false },
);
return Ok(EnsureLocalModelServerOutput {
ready: ready(&config, ModelServerStatus::ReadyStarted),
ready: ready(config, ModelServerStatus::ReadyStarted),
});
}
Ok(ModelServerStatus::Unreachable) => {
@ -356,9 +384,83 @@ impl EnsureLocalModelServer {
}
}
let err = ModelServerError::Timeout;
self.stop_started_server(config.id, &handle).await;
self.fail(config.id, err)
let Some(source) = hf_source else {
let err = ModelServerError::Timeout;
self.stop_started_server(config.id, handle).await;
return self.fail(config.id, err);
};
match self.process.status(handle).await {
Ok(ProcessStatus::Running) => {
self.publish(
config.id,
ModelServerLifecycleStatus::Downloading {
downloaded_bytes: None,
total_bytes: None,
percent: None,
source: Some(source.clone()),
},
);
}
Ok(ProcessStatus::Exited { code }) => {
self.active.lock().unwrap().remove(&config.id);
return self.fail(config.id, premature_exit_error(code));
}
Ok(ProcessStatus::Unknown) => {
self.active.lock().unwrap().remove(&config.id);
return self.fail(
config.id,
ModelServerError::Process("process status unknown".to_owned()),
);
}
Err(err) => return self.fail(config.id, err),
}
let deadline = Instant::now() + self.hf_download_deadline;
loop {
if Instant::now() >= deadline {
let err = ModelServerError::Timeout;
self.stop_started_server(config.id, handle).await;
return self.fail(config.id, err);
}
match self.probe.probe(&config.endpoint).await {
Err(err) => {
self.stop_started_server(config.id, handle).await;
return self.fail(config.id, err);
}
Ok(ModelServerStatus::ReadyReused | ModelServerStatus::ReadyStarted) => {
self.publish(
config.id,
ModelServerLifecycleStatus::Ready { reused: false },
);
return Ok(EnsureLocalModelServerOutput {
ready: ready(config, ModelServerStatus::ReadyStarted),
});
}
Ok(ModelServerStatus::Unreachable) => {}
}
match self.process.status(handle).await {
Ok(ProcessStatus::Running) => {
if !self.readiness.backoff.is_zero() {
tokio::time::sleep(self.readiness.backoff).await;
} else {
tokio::task::yield_now().await;
}
}
Ok(ProcessStatus::Exited { code }) => {
self.active.lock().unwrap().remove(&config.id);
return self.fail(config.id, premature_exit_error(code));
}
Ok(ProcessStatus::Unknown) => {
self.active.lock().unwrap().remove(&config.id);
return self.fail(
config.id,
ModelServerError::Process("process status unknown".to_owned()),
);
}
Err(err) => return self.fail(config.id, err),
}
}
}
/// Stops active servers whose policy is [`StopPolicy::StopOnAppExit`].
@ -497,6 +599,20 @@ fn ready(config: &LocalModelServerConfig, status: ModelServerStatus) -> ModelSer
}
}
fn hf_source(config: &LocalModelServerConfig) -> Option<String> {
match config.model.source.as_ref()? {
ModelSource::HuggingFace { repo } => Some(repo.as_str().to_owned()),
ModelSource::LocalPath { .. } => None,
}
}
fn premature_exit_error(code: Option<i32>) -> ModelServerError {
ModelServerError::Process(match code {
Some(code) => format!("model server exited before readiness with code {code}"),
None => "model server exited before readiness".to_owned(),
})
}
/// Stable model-server error code for event/DTO mapping.
#[must_use]
pub fn model_server_error_code(err: &ModelServerError) -> &'static str {

View File

@ -12,8 +12,9 @@ use application::{
};
use domain::events::DomainEvent;
use domain::model_server::{
ExecutablePath, LlamaCppOptions, LocalModelRef, LocalModelServerConfig, LocalModelServerKind,
ModelPath, ModelServerEndpoint, ModelServerStatus, ModelSource, StopPolicy,
ExecutablePath, HfModelRef, LlamaCppOptions, LocalModelRef, LocalModelServerConfig,
LocalModelServerKind, ModelPath, ModelServerEndpoint, ModelServerLifecycleStatus,
ModelServerStatus, ModelSource, StopPolicy,
};
use domain::ports::{
DirEntry, EventBus, EventStream, FileSystem, FsError, ManagedProcess, ManagedProcessHandle,
@ -56,6 +57,30 @@ fn config(
.unwrap()
}
fn hf_config(id: LocalModelServerId, port: u16, repo: &str) -> LocalModelServerConfig {
LocalModelServerConfig::new(
id,
LocalModelServerKind::LlamaCpp,
"llama.cpp",
ModelServerEndpoint::new(format!("http://localhost:{port}"), port).unwrap(),
LocalModelRef::new(
"qwen",
"Qwen",
Some(ModelSource::HuggingFace {
repo: HfModelRef::new(repo).unwrap(),
}),
"qwen3-coder-30b",
)
.unwrap(),
Some(ExecutablePath::new("llama-server").unwrap()),
LlamaCppOptions::default(),
Vec::new(),
true,
StopPolicy::StopOnAppExit,
)
.unwrap()
}
#[derive(Default)]
struct FakeRegistry(Mutex<HashMap<LocalModelServerId, LocalModelServerConfig>>);
@ -158,6 +183,7 @@ struct FakeProcess {
spawns: Mutex<Vec<SpawnSpec>>,
kills: Mutex<Vec<String>>,
statuses: Mutex<HashMap<String, ProcessStatus>>,
status_sequence: Mutex<VecDeque<ProcessStatus>>,
spawn_delay: Duration,
}
@ -187,6 +213,9 @@ impl ManagedProcess for FakeProcess {
&self,
handle: &ManagedProcessHandle,
) -> Result<ProcessStatus, ModelServerError> {
if let Some(status) = self.status_sequence.lock().unwrap().pop_front() {
return Ok(status);
}
Ok(*self
.statuses
.lock()
@ -203,25 +232,35 @@ impl ModelServerRuntime for FakeRuntime {
&self,
config: &LocalModelServerConfig,
) -> Result<ModelServerArgv, ModelServerError> {
let Some(ModelSource::LocalPath { path }) = config.model.source.as_ref() else {
return Err(ModelServerError::PathNotAccessible(
"model.source missing".to_owned(),
));
};
let mut args = Vec::new();
match config
.model
.source
.as_ref()
.ok_or_else(|| ModelServerError::PathNotAccessible("model.source missing".to_owned()))?
{
ModelSource::LocalPath { path } => {
args.push("--model".to_owned());
args.push(path.as_str().to_owned());
}
ModelSource::HuggingFace { repo } => {
args.push("-hf".to_owned());
args.push(repo.as_str().to_owned());
}
}
args.extend([
"--port".to_owned(),
config.endpoint.port.to_string(),
"--host".to_owned(),
config.options.host.clone(),
]);
Ok(ModelServerArgv {
command: config
.binary
.as_ref()
.map(|binary| binary.as_str().to_owned())
.unwrap_or_else(|| "llama-server".to_owned()),
args: vec![
"--model".to_owned(),
path.as_str().to_owned(),
"--port".to_owned(),
config.endpoint.port.to_string(),
"--host".to_owned(),
config.options.host.clone(),
],
args,
})
}
@ -303,6 +342,7 @@ fn ensure(
attempts: 2,
backoff: Duration::ZERO,
})
.with_hf_download_deadline(Duration::from_secs(5))
}
#[tokio::test]
@ -546,6 +586,158 @@ async fn readiness_timeout_kills_started_process() {
assert_eq!(process.kills.lock().unwrap().as_slice(), ["h1"]);
}
#[tokio::test]
async fn hf_unreachable_alive_publishes_downloading_without_short_timeout_kill() {
let registry = Arc::new(FakeRegistry::default());
registry
.save(hf_config(
sid(11),
8091,
"Qwen/Qwen3-Coder-30B-A3B-Instruct-GGUF",
))
.await
.unwrap();
let process = Arc::new(FakeProcess::default());
let events = Arc::new(FakeEvents::default());
let usecase = ensure(
Arc::clone(&registry),
Arc::new(FakeProbe::new(vec![
ModelServerStatus::Unreachable,
ModelServerStatus::Unreachable,
ModelServerStatus::Unreachable,
ModelServerStatus::ReadyReused,
])),
Arc::clone(&process),
Arc::new(FakeFs::default()),
Arc::clone(&events),
);
let out = usecase
.execute(EnsureLocalModelServerInput { server_id: sid(11) })
.await
.unwrap();
assert_eq!(out.ready.status, ModelServerStatus::ReadyStarted);
assert!(process.kills.lock().unwrap().is_empty());
assert!(events.0.lock().unwrap().iter().any(|event| {
matches!(
event,
DomainEvent::ModelServerStatusChanged {
server_id,
status: ModelServerLifecycleStatus::Downloading {
downloaded_bytes: None,
total_bytes: None,
percent: None,
source: Some(source),
},
} if *server_id == sid(11)
&& source == "Qwen/Qwen3-Coder-30B-A3B-Instruct-GGUF"
)
}));
}
#[tokio::test]
async fn hf_download_phase_reports_ready_when_probe_becomes_ok() {
let registry = Arc::new(FakeRegistry::default());
registry
.save(hf_config(
sid(12),
8092,
"Qwen/Qwen3-Coder-30B-A3B-Instruct-GGUF",
))
.await
.unwrap();
let process = Arc::new(FakeProcess::default());
let events = Arc::new(FakeEvents::default());
let usecase = ensure(
Arc::clone(&registry),
Arc::new(FakeProbe::new(vec![
ModelServerStatus::Unreachable,
ModelServerStatus::Unreachable,
ModelServerStatus::Unreachable,
ModelServerStatus::ReadyStarted,
])),
Arc::clone(&process),
Arc::new(FakeFs::default()),
Arc::clone(&events),
);
let out = usecase
.execute(EnsureLocalModelServerInput { server_id: sid(12) })
.await
.unwrap();
assert_eq!(out.ready.status, ModelServerStatus::ReadyStarted);
let statuses: Vec<ModelServerLifecycleStatus> = events
.0
.lock()
.unwrap()
.iter()
.filter_map(|event| match event {
DomainEvent::ModelServerStatusChanged { status, .. } => Some(status.clone()),
_ => None,
})
.collect();
assert!(matches!(
statuses.as_slice(),
[
ModelServerLifecycleStatus::Probing,
ModelServerLifecycleStatus::Starting,
ModelServerLifecycleStatus::Downloading { .. },
ModelServerLifecycleStatus::Ready { reused: false },
]
));
}
#[tokio::test]
async fn hf_download_phase_fails_when_process_exits() {
let registry = Arc::new(FakeRegistry::default());
registry
.save(hf_config(
sid(13),
8093,
"Qwen/Qwen3-Coder-30B-A3B-Instruct-GGUF",
))
.await
.unwrap();
let process = Arc::new(FakeProcess::default());
process
.status_sequence
.lock()
.unwrap()
.push_back(ProcessStatus::Exited { code: Some(42) });
let events = Arc::new(FakeEvents::default());
let usecase = ensure(
Arc::clone(&registry),
Arc::new(FakeProbe::new(vec![
ModelServerStatus::Unreachable,
ModelServerStatus::Unreachable,
ModelServerStatus::Unreachable,
])),
Arc::clone(&process),
Arc::new(FakeFs::default()),
Arc::clone(&events),
);
let err = usecase
.execute(EnsureLocalModelServerInput { server_id: sid(13) })
.await
.unwrap_err();
assert!(err.to_string().contains("process"));
assert!(err.to_string().contains("42"));
assert!(process.kills.lock().unwrap().is_empty());
assert!(events.0.lock().unwrap().iter().any(|event| {
matches!(
event,
DomainEvent::ModelServerStatusChanged {
status: ModelServerLifecycleStatus::Failed { code, .. },
..
} if code == "process"
)
}));
}
#[tokio::test]
async fn missing_registry_entry_is_model_server_not_configured() {
let usecase = ensure(