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:
@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user