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:
@ -66,7 +66,8 @@ pub use inspector::{
|
||||
pub use issues::{FsIssueNumberAllocator, FsIssueStore};
|
||||
pub use mailbox::InMemoryMailbox;
|
||||
pub use model_server::{
|
||||
FsModelServerRegistry, HttpOpenAiCompatibleProbe, LlamaCppRuntime, LocalManagedProcess,
|
||||
FsModelServerRegistry, HfModelArtifactDownloader, HttpOpenAiCompatibleProbe, LlamaCppRuntime,
|
||||
LocalManagedProcess,
|
||||
};
|
||||
pub use orchestrator::mcp::{
|
||||
McpServer, MemoryTransport, StdioTransport, TicketToolError, TicketToolProvider,
|
||||
|
||||
@ -6,15 +6,18 @@ use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use futures_util::StreamExt;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::process::{Child, Command};
|
||||
|
||||
use domain::model_server::{
|
||||
ExecutablePath, LlamaCppOptions, LocalModelRef, LocalModelServerConfig, LocalModelServerKind,
|
||||
ModelPath, ModelServerEndpoint, ModelSource,
|
||||
ExecutablePath, HfModelRef, LlamaCppOptions, LocalModelRef, LocalModelServerConfig,
|
||||
LocalModelServerKind, ModelPath, ModelServerEndpoint, ModelSource,
|
||||
};
|
||||
use domain::ports::{
|
||||
FileSystem, ManagedProcess, ManagedProcessHandle, ModelServerArgv, ModelServerError,
|
||||
FileSystem, ManagedProcess, ManagedProcessHandle, ModelArtifactCancel, ModelArtifactDownloader,
|
||||
ModelArtifactProgress, ModelArtifactResolution, ModelServerArgv, ModelServerError,
|
||||
ModelServerProbe, ModelServerRegistry, ModelServerRuntime, ProcessStatus, RemotePath,
|
||||
SpawnSpec,
|
||||
};
|
||||
@ -68,6 +71,210 @@ fn is_ready(result: Result<reqwest::Response, reqwest::Error>) -> bool {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Hugging Face artifact resolver/downloader backed by an IdeA-owned cache dir.
|
||||
#[derive(Clone)]
|
||||
pub struct HfModelArtifactDownloader {
|
||||
cache_dir: PathBuf,
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl HfModelArtifactDownloader {
|
||||
/// Builds a downloader that stores artifacts under `cache_dir`.
|
||||
#[must_use]
|
||||
pub fn new(cache_dir: impl Into<PathBuf>) -> Self {
|
||||
Self {
|
||||
cache_dir: cache_dir.into(),
|
||||
client: reqwest::Client::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Deterministic cache path for a Hugging Face model reference.
|
||||
#[must_use]
|
||||
pub fn cache_path_for(&self, repo: &HfModelRef) -> PathBuf {
|
||||
let (base, quant) = split_hf_ref(repo);
|
||||
let file_stem = quant.unwrap_or("model");
|
||||
self.cache_dir
|
||||
.join(base.replace('/', "--"))
|
||||
.join(format!("{}.gguf", safe_cache_component(file_stem)))
|
||||
}
|
||||
|
||||
async fn resolve_remote_filename(&self, repo: &HfModelRef) -> Result<String, ModelServerError> {
|
||||
let (base, quant) = split_hf_ref(repo);
|
||||
let url = format!("https://huggingface.co/api/models/{base}");
|
||||
let response = self
|
||||
.client
|
||||
.get(url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ModelServerError::Probe(e.to_string()))?;
|
||||
if !response.status().is_success() {
|
||||
return Err(ModelServerError::Probe(format!(
|
||||
"huggingface model metadata returned {}",
|
||||
response.status()
|
||||
)));
|
||||
}
|
||||
let metadata: HfModelMetadata = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ModelServerError::Probe(e.to_string()))?;
|
||||
select_gguf_file(&metadata.siblings, quant).ok_or_else(|| {
|
||||
ModelServerError::PathNotAccessible(format!(
|
||||
"no matching .gguf artifact found for {}",
|
||||
repo.as_str()
|
||||
))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ModelArtifactDownloader for HfModelArtifactDownloader {
|
||||
async fn resolve_hf_model(
|
||||
&self,
|
||||
repo: &HfModelRef,
|
||||
progress: std::sync::Arc<dyn Fn(ModelArtifactProgress) + Send + Sync>,
|
||||
cancel: ModelArtifactCancel,
|
||||
) -> Result<ModelArtifactResolution, ModelServerError> {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(ModelServerError::Cancelled);
|
||||
}
|
||||
let path = self.cache_path_for(repo);
|
||||
if path.is_file() {
|
||||
return Ok(ModelArtifactResolution {
|
||||
path: model_path_from_pathbuf(path)?,
|
||||
cache_hit: true,
|
||||
});
|
||||
}
|
||||
|
||||
let filename = self.resolve_remote_filename(repo).await?;
|
||||
if cancel.is_cancelled() {
|
||||
return Err(ModelServerError::Cancelled);
|
||||
}
|
||||
let (base, _) = split_hf_ref(repo);
|
||||
let url = format!(
|
||||
"https://huggingface.co/{base}/resolve/main/{}",
|
||||
url_path_segment(&filename)
|
||||
);
|
||||
let response = self
|
||||
.client
|
||||
.get(url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ModelServerError::Probe(e.to_string()))?;
|
||||
if !response.status().is_success() {
|
||||
return Err(ModelServerError::Probe(format!(
|
||||
"huggingface artifact download returned {}",
|
||||
response.status()
|
||||
)));
|
||||
}
|
||||
if let Some(parent) = path.parent() {
|
||||
tokio::fs::create_dir_all(parent)
|
||||
.await
|
||||
.map_err(|e| ModelServerError::Store(e.to_string()))?;
|
||||
}
|
||||
let tmp_path = path.with_extension("gguf.part");
|
||||
let mut file = tokio::fs::File::create(&tmp_path)
|
||||
.await
|
||||
.map_err(|e| ModelServerError::Store(e.to_string()))?;
|
||||
let total = response.content_length();
|
||||
let mut downloaded = 0_u64;
|
||||
let mut stream = response.bytes_stream();
|
||||
while let Some(chunk) = stream.next().await {
|
||||
if cancel.is_cancelled() {
|
||||
let _ = tokio::fs::remove_file(&tmp_path).await;
|
||||
return Err(ModelServerError::Cancelled);
|
||||
}
|
||||
let chunk = chunk.map_err(|e| ModelServerError::Probe(e.to_string()))?;
|
||||
file.write_all(&chunk)
|
||||
.await
|
||||
.map_err(|e| ModelServerError::Store(e.to_string()))?;
|
||||
downloaded += chunk.len() as u64;
|
||||
progress(ModelArtifactProgress {
|
||||
downloaded_bytes: Some(downloaded),
|
||||
total_bytes: total,
|
||||
source: Some(repo.as_str().to_owned()),
|
||||
});
|
||||
}
|
||||
file.flush()
|
||||
.await
|
||||
.map_err(|e| ModelServerError::Store(e.to_string()))?;
|
||||
drop(file);
|
||||
tokio::fs::rename(&tmp_path, &path)
|
||||
.await
|
||||
.map_err(|e| ModelServerError::Store(e.to_string()))?;
|
||||
Ok(ModelArtifactResolution {
|
||||
path: model_path_from_pathbuf(path)?,
|
||||
cache_hit: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct HfModelMetadata {
|
||||
#[serde(default)]
|
||||
siblings: Vec<HfSibling>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct HfSibling {
|
||||
#[serde(rename = "rfilename")]
|
||||
filename: String,
|
||||
}
|
||||
|
||||
fn split_hf_ref(repo: &HfModelRef) -> (&str, Option<&str>) {
|
||||
repo.as_str()
|
||||
.split_once(':')
|
||||
.map_or((repo.as_str(), None), |(base, quant)| (base, Some(quant)))
|
||||
}
|
||||
|
||||
fn select_gguf_file(siblings: &[HfSibling], quant: Option<&str>) -> Option<String> {
|
||||
let mut ggufs: Vec<&str> = siblings
|
||||
.iter()
|
||||
.map(|sibling| sibling.filename.as_str())
|
||||
.filter(|filename| filename.ends_with(".gguf"))
|
||||
.collect();
|
||||
ggufs.sort_unstable();
|
||||
let Some(quant) = quant else {
|
||||
return ggufs.first().map(|filename| (*filename).to_owned());
|
||||
};
|
||||
let quant = quant.to_ascii_lowercase();
|
||||
ggufs
|
||||
.into_iter()
|
||||
.find(|filename| filename.to_ascii_lowercase().contains(&quant))
|
||||
.map(str::to_owned)
|
||||
}
|
||||
|
||||
fn safe_cache_component(raw: &str) -> String {
|
||||
raw.chars()
|
||||
.map(|ch| match ch {
|
||||
'A'..='Z' | 'a'..='z' | '0'..='9' | '.' | '_' | '-' => ch,
|
||||
_ => '_',
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn url_path_segment(raw: &str) -> String {
|
||||
raw.split('/')
|
||||
.map(url_component)
|
||||
.collect::<Vec<_>>()
|
||||
.join("/")
|
||||
}
|
||||
|
||||
fn url_component(raw: &str) -> String {
|
||||
raw.bytes()
|
||||
.flat_map(|byte| match byte {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'.' | b'_' | b'-' => {
|
||||
vec![byte as char]
|
||||
}
|
||||
other => format!("%{other:02X}").chars().collect(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn model_path_from_pathbuf(path: PathBuf) -> Result<ModelPath, ModelServerError> {
|
||||
ModelPath::new(path.to_string_lossy().into_owned())
|
||||
.map_err(|e| ModelServerError::Invalid(e.to_string()))
|
||||
}
|
||||
|
||||
/// Builds `llama-server` argv without shell interpolation.
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub struct LlamaCppRuntime;
|
||||
|
||||
Reference in New Issue
Block a user