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

@ -74,10 +74,10 @@ use infrastructure::{
FsEmbedderPromptStore, FsHandoffStore, FsIssueNumberAllocator, FsIssueStore, FsLiveStateStore,
FsMemoryStore, FsModelServerRegistry, FsOrchestratorWatcher, FsPermissionStore, FsProfileStore,
FsProjectStore, FsProviderSessionStore, FsSkillStore, FsSprintStore, FsTemplateStore,
FsWindowStateStore, Git2Repository, HeuristicHandoffSummarizer, HttpOpenAiCompatibleProbe,
IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox, LlamaCppRuntime,
LocalFileSystem, LocalManagedProcess, LocalProcessSpawner, McpServer, MediatedInbox,
NaiveMemoryRecall, OrchestratorWatchHandle, PortablePtyAdapter, RwFileGuard,
FsWindowStateStore, Git2Repository, HeuristicHandoffSummarizer, HfModelArtifactDownloader,
HttpOpenAiCompatibleProbe, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox,
LlamaCppRuntime, LocalFileSystem, LocalManagedProcess, LocalProcessSpawner, McpServer,
MediatedInbox, NaiveMemoryRecall, OrchestratorWatchHandle, PortablePtyAdapter, RwFileGuard,
StructuredSessionFactory, SystemClock, SystemMillisClock, TicketAssistantEnvironmentPreparer,
TicketToolProvider, TokioBroadcastEventBus, TokioScheduler, ToolPolicyRegistry, UuidGenerator,
VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS,
@ -1234,15 +1234,23 @@ impl AppState {
Arc::clone(&fs_port),
app_data_dir.to_string_lossy().into_owned(),
));
let ensure_local_model_server = Arc::new(EnsureLocalModelServer::new(
Arc::clone(&model_server_registry) as Arc<dyn domain::ports::ModelServerRegistry>,
Arc::new(HttpOpenAiCompatibleProbe::default())
as Arc<dyn domain::ports::ModelServerProbe>,
Arc::new(LocalManagedProcess::new()) as Arc<dyn domain::ports::ManagedProcess>,
Arc::new(LlamaCppRuntime::new()) as Arc<dyn domain::ports::ModelServerRuntime>,
Arc::clone(&fs_port),
Arc::clone(&events_port),
let model_artifact_downloader = Arc::new(HfModelArtifactDownloader::new(
app_data_dir.join("hf-model-artifacts"),
));
let ensure_local_model_server = Arc::new(
EnsureLocalModelServer::new(
Arc::clone(&model_server_registry) as Arc<dyn domain::ports::ModelServerRegistry>,
Arc::new(HttpOpenAiCompatibleProbe::default())
as Arc<dyn domain::ports::ModelServerProbe>,
Arc::new(LocalManagedProcess::new()) as Arc<dyn domain::ports::ManagedProcess>,
Arc::new(LlamaCppRuntime::new()) as Arc<dyn domain::ports::ModelServerRuntime>,
Arc::clone(&fs_port),
Arc::clone(&events_port),
)
.with_model_artifact_downloader(
model_artifact_downloader as Arc<dyn domain::ports::ModelArtifactDownloader>,
),
);
let model_server_registry_port =
Arc::clone(&model_server_registry) as Arc<dyn domain::ports::ModelServerRegistry>;
let list_model_servers = Arc::new(ListModelServers::new(Arc::clone(

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
)
}

View File

@ -18,6 +18,7 @@ use domain::model_server::{
};
use domain::ports::{
DirEntry, EventBus, EventStream, FileSystem, FsError, ManagedProcess, ManagedProcessHandle,
ModelArtifactCancel, ModelArtifactDownloader, ModelArtifactProgress, ModelArtifactResolution,
ModelServerArgv, ModelServerError, ModelServerProbe, ModelServerRegistry, ModelServerRuntime,
ProcessStatus, ProfileStore, RemotePath, SpawnSpec, StoreError,
};
@ -280,6 +281,74 @@ impl ModelServerRuntime for FakeRuntime {
}
}
#[derive(Clone)]
enum FakeDownloadOutcome {
Resolve {
progress: Vec<ModelArtifactProgress>,
path: &'static str,
cache_hit: bool,
},
WaitForCancel,
Sleep(Duration),
}
struct FakeModelArtifactDownloader {
outcome: Mutex<FakeDownloadOutcome>,
}
impl FakeModelArtifactDownloader {
fn new(outcome: FakeDownloadOutcome) -> Self {
Self {
outcome: Mutex::new(outcome),
}
}
}
#[async_trait]
impl ModelArtifactDownloader for FakeModelArtifactDownloader {
async fn resolve_hf_model(
&self,
repo: &HfModelRef,
progress: Arc<dyn Fn(ModelArtifactProgress) + Send + Sync>,
cancel: ModelArtifactCancel,
) -> Result<ModelArtifactResolution, ModelServerError> {
let outcome = self.outcome.lock().unwrap().clone();
match outcome {
FakeDownloadOutcome::Resolve {
progress: events,
path,
cache_hit,
} => {
if cancel.is_cancelled() {
return Err(ModelServerError::Cancelled);
}
if !cache_hit {
for event in events {
progress(ModelArtifactProgress {
source: event.source.or_else(|| Some(repo.as_str().to_owned())),
..event
});
}
}
Ok(ModelArtifactResolution {
path: ModelPath::new(path).unwrap(),
cache_hit,
})
}
FakeDownloadOutcome::WaitForCancel => loop {
if cancel.is_cancelled() {
return Err(ModelServerError::Cancelled);
}
tokio::time::sleep(Duration::from_millis(1)).await;
},
FakeDownloadOutcome::Sleep(duration) => {
tokio::time::sleep(duration).await;
Err(ModelServerError::Probe("unexpected wake".to_owned()))
}
}
}
}
#[derive(Default)]
struct FakeFs {
existing: Mutex<Vec<String>>,
@ -345,6 +414,26 @@ fn ensure(
.with_hf_download_deadline(Duration::from_secs(5))
}
fn ensure_with_downloader(
registry: Arc<FakeRegistry>,
probe: Arc<FakeProbe>,
process: Arc<FakeProcess>,
fs: Arc<FakeFs>,
events: Arc<FakeEvents>,
downloader: Arc<FakeModelArtifactDownloader>,
) -> EnsureLocalModelServer {
ensure(registry, probe, process, fs, events)
.with_model_artifact_downloader(downloader as Arc<dyn ModelArtifactDownloader>)
}
fn progress(downloaded: Option<u64>, total: Option<u64>) -> ModelArtifactProgress {
ModelArtifactProgress {
downloaded_bytes: downloaded,
total_bytes: total,
source: None,
}
}
#[tokio::test]
async fn reachable_server_is_reused_without_spawn() {
let registry = Arc::new(FakeRegistry::default());
@ -738,6 +827,270 @@ async fn hf_download_phase_fails_when_process_exits() {
}));
}
#[tokio::test]
async fn hf_downloader_publishes_debounced_progress_and_spawns_resolved_model_path() {
let registry = Arc::new(FakeRegistry::default());
registry
.save(hf_config(
sid(14),
8094,
"Qwen/Qwen3-Coder-30B-A3B-Instruct-GGUF",
))
.await
.unwrap();
let process = Arc::new(FakeProcess::default());
let events = Arc::new(FakeEvents::default());
let downloader = Arc::new(FakeModelArtifactDownloader::new(
FakeDownloadOutcome::Resolve {
progress: vec![
progress(Some(10), Some(100)),
progress(Some(50), Some(100)),
progress(Some(100), Some(100)),
],
path: "/cache/model.gguf",
cache_hit: false,
},
));
let usecase = ensure_with_downloader(
Arc::clone(&registry),
Arc::new(FakeProbe::new(vec![
ModelServerStatus::Unreachable,
ModelServerStatus::ReadyStarted,
])),
Arc::clone(&process),
Arc::new(FakeFs::default()),
Arc::clone(&events),
downloader,
);
let out = usecase
.execute(EnsureLocalModelServerInput { server_id: sid(14) })
.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::Downloading {
downloaded_bytes: Some(10),
total_bytes: Some(100),
percent: Some(10.0),
..
},
ModelServerLifecycleStatus::Downloading {
downloaded_bytes: Some(100),
total_bytes: Some(100),
percent: Some(100.0),
..
},
ModelServerLifecycleStatus::Starting,
ModelServerLifecycleStatus::Ready { reused: false },
]
));
let spawns = process.spawns.lock().unwrap();
assert_eq!(spawns.len(), 1);
assert_eq!(spawns[0].args[0..2], ["--model", "/cache/model.gguf"]);
assert!(!spawns[0].args.iter().any(|arg| arg == "-hf"));
}
#[tokio::test]
async fn hf_downloader_cache_hit_skips_downloading_events() {
let registry = Arc::new(FakeRegistry::default());
registry
.save(hf_config(sid(15), 8095, "Qwen/Qwen3-Coder:Q4_K_M"))
.await
.unwrap();
let process = Arc::new(FakeProcess::default());
let events = Arc::new(FakeEvents::default());
let downloader = Arc::new(FakeModelArtifactDownloader::new(
FakeDownloadOutcome::Resolve {
progress: Vec::new(),
path: "/cache/q4.gguf",
cache_hit: true,
},
));
let usecase = ensure_with_downloader(
Arc::clone(&registry),
Arc::new(FakeProbe::new(vec![
ModelServerStatus::Unreachable,
ModelServerStatus::ReadyStarted,
])),
Arc::clone(&process),
Arc::new(FakeFs::default()),
Arc::clone(&events),
downloader,
);
usecase
.execute(EnsureLocalModelServerInput { server_id: sid(15) })
.await
.unwrap();
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::Ready { reused: false },
]
));
}
#[tokio::test]
async fn hf_downloader_unknown_total_publishes_no_percent() {
let registry = Arc::new(FakeRegistry::default());
registry
.save(hf_config(sid(16), 8096, "Qwen/Qwen3-Coder:Q4_K_M"))
.await
.unwrap();
let process = Arc::new(FakeProcess::default());
let events = Arc::new(FakeEvents::default());
let downloader = Arc::new(FakeModelArtifactDownloader::new(
FakeDownloadOutcome::Resolve {
progress: vec![progress(Some(10), None)],
path: "/cache/q4.gguf",
cache_hit: false,
},
));
let usecase = ensure_with_downloader(
Arc::clone(&registry),
Arc::new(FakeProbe::new(vec![
ModelServerStatus::Unreachable,
ModelServerStatus::ReadyStarted,
])),
Arc::clone(&process),
Arc::new(FakeFs::default()),
Arc::clone(&events),
downloader,
);
usecase
.execute(EnsureLocalModelServerInput { server_id: sid(16) })
.await
.unwrap();
assert!(events.0.lock().unwrap().iter().any(|event| {
matches!(
event,
DomainEvent::ModelServerStatusChanged {
status: ModelServerLifecycleStatus::Downloading {
downloaded_bytes: Some(10),
total_bytes: None,
percent: None,
..
},
..
}
)
}));
}
#[tokio::test]
async fn hf_downloader_cancel_fails_without_process_spawn() {
let registry = Arc::new(FakeRegistry::default());
registry
.save(hf_config(sid(17), 8097, "Qwen/Qwen3-Coder:Q4_K_M"))
.await
.unwrap();
let process = Arc::new(FakeProcess::default());
let events = Arc::new(FakeEvents::default());
let downloader = Arc::new(FakeModelArtifactDownloader::new(
FakeDownloadOutcome::WaitForCancel,
));
let usecase = Arc::new(ensure_with_downloader(
Arc::clone(&registry),
Arc::new(FakeProbe::new(vec![ModelServerStatus::Unreachable])),
Arc::clone(&process),
Arc::new(FakeFs::default()),
Arc::clone(&events),
downloader,
));
let task_usecase = Arc::clone(&usecase);
let task = tokio::spawn(async move {
task_usecase
.execute(EnsureLocalModelServerInput { server_id: sid(17) })
.await
});
tokio::time::sleep(Duration::from_millis(5)).await;
usecase.stop_on_app_exit().await.unwrap();
let err = task.await.unwrap().unwrap_err();
assert!(err.to_string().contains("cancelled"));
assert!(process.spawns.lock().unwrap().is_empty());
assert!(events.0.lock().unwrap().iter().any(|event| {
matches!(
event,
DomainEvent::ModelServerStatusChanged {
status: ModelServerLifecycleStatus::Failed { code, .. },
..
} if code == "cancelled"
)
}));
}
#[tokio::test]
async fn hf_downloader_timeout_fails_without_process_spawn() {
let registry = Arc::new(FakeRegistry::default());
registry
.save(hf_config(sid(18), 8098, "Qwen/Qwen3-Coder:Q4_K_M"))
.await
.unwrap();
let process = Arc::new(FakeProcess::default());
let events = Arc::new(FakeEvents::default());
let downloader = Arc::new(FakeModelArtifactDownloader::new(
FakeDownloadOutcome::Sleep(Duration::from_millis(50)),
));
let usecase = ensure_with_downloader(
Arc::clone(&registry),
Arc::new(FakeProbe::new(vec![ModelServerStatus::Unreachable])),
Arc::clone(&process),
Arc::new(FakeFs::default()),
Arc::clone(&events),
downloader,
)
.with_hf_download_deadline(Duration::from_millis(1));
let err = usecase
.execute(EnsureLocalModelServerInput { server_id: sid(18) })
.await
.unwrap_err();
assert!(err.to_string().contains("timeout"));
assert!(process.spawns.lock().unwrap().is_empty());
assert!(events.0.lock().unwrap().iter().any(|event| {
matches!(
event,
DomainEvent::ModelServerStatusChanged {
status: ModelServerLifecycleStatus::Failed { code, .. },
..
} if code == "timeout"
)
}));
}
#[tokio::test]
async fn missing_registry_entry_is_model_server_not_configured() {
let usecase = ensure(

View File

@ -199,10 +199,11 @@ pub use ports::{
EmbedderEnvReport, EmbedderError, EmbedderProfileStore, EmbedderPromptDismissal,
EmbedderPromptStore, EventBus, EventStream, ExitStatus, FileSystem, FsError, GitCommitInfo,
GitError, GitFileStatus, GitPort, GraphCommit, IdGenerator, IssueNumberAllocator, IssueStore,
IssueStoreError, LiveStateStore, MemoryError, MemoryQuery, MemoryRecall, MemoryStore, Output,
OutputStream, PermissionStore, PreparedContext, ProcessError, ProcessSpawner, ProfileStore,
ProjectStore, PtyError, PtyHandle, PtyPort, RemoteError, RemoteHost, RemotePath, RuntimeError,
ScheduledTask, Scheduler, SpawnSpec, SprintStore, SprintStoreError, StoreError,
IssueStoreError, LiveStateStore, MemoryError, MemoryQuery, MemoryRecall, MemoryStore,
ModelArtifactCancel, ModelArtifactDownloader, ModelArtifactProgress, ModelArtifactResolution,
Output, OutputStream, PermissionStore, PreparedContext, ProcessError, ProcessSpawner,
ProfileStore, ProjectStore, PtyError, PtyHandle, PtyPort, RemoteError, RemoteHost, RemotePath,
RuntimeError, ScheduledTask, Scheduler, SpawnSpec, SprintStore, SprintStoreError, StoreError,
StructuredSessionEnvironment, StructuredSessionEnvironmentPreparer, TemplateStore,
WindowStateStore,
};

View File

@ -22,6 +22,7 @@
//! Each port is **fine-grained** (Interface Segregation): `FileSystem`,
//! `PtyPort`, `ProcessSpawner` are separate, never a `System` god-trait.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use async_trait::async_trait;
@ -42,7 +43,9 @@ use crate::issue::{
};
use crate::markdown::MarkdownDoc;
use crate::memory::{Memory, MemoryIndexEntry, MemoryLink, MemorySlug};
use crate::model_server::{LocalModelServerConfig, ModelServerEndpoint, ModelServerStatus};
use crate::model_server::{
HfModelRef, LocalModelServerConfig, ModelPath, ModelServerEndpoint, ModelServerStatus,
};
use crate::permission::ProjectPermissions;
use crate::profile::{AgentProfile, EmbedderProfile};
use crate::project::{Project, ProjectPath};
@ -582,6 +585,9 @@ pub enum ModelServerError {
/// Store operation failed.
#[error("store failed: {0}")]
Store(String),
/// Operation was cancelled by the caller.
#[error("cancelled")]
Cancelled,
/// Readiness timed out.
#[error("readiness timed out")]
Timeout,
@ -1013,6 +1019,66 @@ pub trait ModelServerProbe: Send + Sync {
) -> Result<ModelServerStatus, ModelServerError>;
}
/// Progress observed while resolving/downloading a model artifact.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ModelArtifactProgress {
/// Downloaded bytes when known.
pub downloaded_bytes: Option<u64>,
/// Total bytes when known.
pub total_bytes: Option<u64>,
/// Source being resolved, usually the Hugging Face repo reference.
pub source: Option<String>,
}
/// Resolved local model artifact.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ModelArtifactResolution {
/// Absolute local path to the resolved `.gguf` file.
pub path: ModelPath,
/// `true` when the artifact was already available in cache.
pub cache_hit: bool,
}
/// Cooperative cancellation token for model artifact resolution.
#[derive(Debug, Clone, Default)]
pub struct ModelArtifactCancel {
cancelled: Arc<AtomicBool>,
}
impl ModelArtifactCancel {
/// Creates a non-cancelled token.
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Requests cancellation.
pub fn cancel(&self) {
self.cancelled.store(true, Ordering::SeqCst);
}
/// Returns whether cancellation was requested.
#[must_use]
pub fn is_cancelled(&self) -> bool {
self.cancelled.load(Ordering::SeqCst)
}
}
/// Resolves or downloads a model artifact before starting a model server.
#[async_trait]
pub trait ModelArtifactDownloader: Send + Sync {
/// Resolves a Hugging Face model to a local artifact path.
///
/// # Errors
/// [`ModelServerError`] when resolution/download fails or is cancelled.
async fn resolve_hf_model(
&self,
repo: &HfModelRef,
progress: Arc<dyn Fn(ModelArtifactProgress) + Send + Sync>,
cancel: ModelArtifactCancel,
) -> Result<ModelArtifactResolution, ModelServerError>;
}
/// Manages local long-lived child processes.
#[async_trait]
pub trait ManagedProcess: Send + Sync {

View File

@ -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,

View File

@ -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;

View File

@ -7,9 +7,14 @@ use domain::model_server::{
ExecutablePath, HfModelRef, LlamaCppOptions, LocalModelRef, LocalModelServerConfig,
LocalModelServerKind, ModelPath, ModelServerEndpoint, ModelSource, StopPolicy,
};
use domain::ports::{FileSystem, ModelServerRegistry, ModelServerRuntime, RemotePath};
use domain::ports::{
FileSystem, ModelArtifactCancel, ModelArtifactDownloader, ModelServerRegistry,
ModelServerRuntime, RemotePath,
};
use domain::LocalModelServerId;
use infrastructure::{FsModelServerRegistry, LlamaCppRuntime, LocalFileSystem};
use infrastructure::{
FsModelServerRegistry, HfModelArtifactDownloader, LlamaCppRuntime, LocalFileSystem,
};
use uuid::Uuid;
struct TempDir(PathBuf);
@ -28,6 +33,10 @@ impl TempDir {
fn child(&self, name: &str) -> RemotePath {
RemotePath::new(self.0.join(name).to_string_lossy().into_owned())
}
fn path(&self) -> &std::path::Path {
&self.0
}
}
impl Drop for TempDir {
@ -189,3 +198,29 @@ async fn fs_model_server_registry_migrates_v1_json_to_v2() {
})
);
}
#[tokio::test]
async fn hf_model_artifact_downloader_resolves_deterministic_local_cache_hit_without_network() {
let tmp = TempDir::new();
let downloader = HfModelArtifactDownloader::new(tmp.path());
let repo = HfModelRef::new("Qwen/Qwen3-Coder:Q4_K_M").unwrap();
let path = downloader.cache_path_for(&repo);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, b"gguf").unwrap();
let resolution = downloader
.resolve_hf_model(
&repo,
Arc::new(|_| panic!("cache hit must not report progress")),
ModelArtifactCancel::new(),
)
.await
.unwrap();
assert!(resolution.cache_hit);
assert_eq!(resolution.path.as_str(), path.to_string_lossy());
assert_eq!(
path.strip_prefix(tmp.path()).unwrap(),
std::path::Path::new("Qwen--Qwen3-Coder").join("Q4_K_M.gguf")
);
}