- #100: plugin_review_package prend ReviewPluginPackageDto valide (source_kind) - #101: opencode_model_server_block débloque modèle quand pas utilisé - Tests verts: plugin.test.ts + model_server.rs
1112 lines
37 KiB
Rust
1112 lines
37 KiB
Rust
//! Use cases for local model servers.
|
|
|
|
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::{
|
|
LocalModelServerConfig, ModelServerLifecycleStatus, ModelServerReady, ModelServerStatus,
|
|
ModelSource,
|
|
};
|
|
use domain::ports::{
|
|
AgentContextStore, EventBus, FileSystem, ManagedProcess, ManagedProcessHandle,
|
|
ModelArtifactCancel, ModelArtifactDownloader, ModelArtifactProgress, ModelArtifactState,
|
|
ModelServerError, ModelServerProbe, ModelServerRegistry, ModelServerRuntime, ProcessStatus,
|
|
ProfileStore, ProjectStore, RemotePath,
|
|
};
|
|
use domain::{LocalModelServerId, StopPolicy};
|
|
use tokio::sync::{Mutex as AsyncMutex, Notify};
|
|
use tokio::time::Instant;
|
|
|
|
use crate::error::AppError;
|
|
use crate::terminal::LiveAgentRegistry;
|
|
|
|
/// Artifact cache state exposed by model-server list use cases.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum ModelArtifactView {
|
|
/// The configured source is not managed by IdeA's downloader.
|
|
NotManaged,
|
|
/// The configured source is managed but not present in cache.
|
|
Missing,
|
|
/// A download/prepare operation is currently running for this server.
|
|
Downloading,
|
|
/// The configured source is present in cache.
|
|
Downloaded {
|
|
/// Local artifact path used by llama.cpp.
|
|
path: String,
|
|
/// Total on-disk size when known.
|
|
size_bytes: Option<u64>,
|
|
},
|
|
}
|
|
|
|
impl From<ModelArtifactState> for ModelArtifactView {
|
|
fn from(state: ModelArtifactState) -> Self {
|
|
match state {
|
|
ModelArtifactState::NotManaged => Self::NotManaged,
|
|
ModelArtifactState::Missing => Self::Missing,
|
|
ModelArtifactState::Downloaded { path, size_bytes } => Self::Downloaded {
|
|
path: path.as_str().to_owned(),
|
|
size_bytes,
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A configured local model server plus derived artifact state.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct ModelServerListItem {
|
|
/// Persisted local model-server config.
|
|
pub config: LocalModelServerConfig,
|
|
/// Derived artifact cache state.
|
|
pub artifact: ModelArtifactView,
|
|
}
|
|
|
|
/// Output of [`ListModelServers::execute`].
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct ListModelServersOutput {
|
|
/// Persisted local model-server configs enriched with artifact state.
|
|
pub servers: Vec<ModelServerListItem>,
|
|
}
|
|
|
|
/// Lists local model-server configurations.
|
|
pub struct ListModelServers {
|
|
registry: Arc<dyn ModelServerRegistry>,
|
|
downloader: Option<Arc<dyn ModelArtifactDownloader>>,
|
|
downloads: Option<Arc<dyn ModelArtifactDownloadTracker>>,
|
|
}
|
|
|
|
impl ListModelServers {
|
|
/// Builds the use case.
|
|
#[must_use]
|
|
pub fn new(registry: Arc<dyn ModelServerRegistry>) -> Self {
|
|
Self {
|
|
registry,
|
|
downloader: None,
|
|
downloads: None,
|
|
}
|
|
}
|
|
|
|
/// Enables artifact state enrichment for Hugging Face-backed servers.
|
|
#[must_use]
|
|
pub fn with_model_artifact_downloader(
|
|
mut self,
|
|
downloader: Arc<dyn ModelArtifactDownloader>,
|
|
) -> Self {
|
|
self.downloader = Some(downloader);
|
|
self
|
|
}
|
|
|
|
/// Enables in-flight download state enrichment.
|
|
#[must_use]
|
|
pub fn with_download_tracker(
|
|
mut self,
|
|
downloads: Arc<dyn ModelArtifactDownloadTracker>,
|
|
) -> Self {
|
|
self.downloads = Some(downloads);
|
|
self
|
|
}
|
|
|
|
/// Lists configs.
|
|
///
|
|
/// # Errors
|
|
/// [`AppError::ModelServer`] on registry failure.
|
|
pub async fn execute(&self) -> Result<ListModelServersOutput, AppError> {
|
|
let configs = self.registry.list().await?;
|
|
let mut servers = Vec::with_capacity(configs.len());
|
|
for config in configs {
|
|
let artifact = self.artifact_view(&config).await?;
|
|
servers.push(ModelServerListItem { config, artifact });
|
|
}
|
|
Ok(ListModelServersOutput { servers })
|
|
}
|
|
|
|
async fn artifact_view(
|
|
&self,
|
|
config: &LocalModelServerConfig,
|
|
) -> Result<ModelArtifactView, AppError> {
|
|
if self
|
|
.downloads
|
|
.as_ref()
|
|
.is_some_and(|downloads| downloads.is_model_artifact_download_in_progress(config.id))
|
|
{
|
|
return Ok(ModelArtifactView::Downloading);
|
|
}
|
|
let Some(ModelSource::HuggingFace { repo }) = config.model.source.as_ref() else {
|
|
return Ok(ModelArtifactView::NotManaged);
|
|
};
|
|
let Some(downloader) = self.downloader.as_ref() else {
|
|
return Ok(ModelArtifactView::Missing);
|
|
};
|
|
downloader
|
|
.hf_model_state(repo)
|
|
.await
|
|
.map(ModelArtifactView::from)
|
|
.map_err(AppError::from)
|
|
}
|
|
}
|
|
|
|
/// Read-only in-flight download state shared by list/delete use cases.
|
|
pub trait ModelArtifactDownloadTracker: Send + Sync {
|
|
/// Whether the model artifact for `server_id` is currently being resolved/downloaded.
|
|
fn is_model_artifact_download_in_progress(&self, server_id: LocalModelServerId) -> bool;
|
|
}
|
|
|
|
/// Input for [`SaveModelServer::execute`].
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct SaveModelServerInput {
|
|
/// Config to upsert by id.
|
|
pub config: LocalModelServerConfig,
|
|
}
|
|
|
|
/// Output of [`SaveModelServer::execute`].
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct SaveModelServerOutput {
|
|
/// Saved config.
|
|
pub config: LocalModelServerConfig,
|
|
}
|
|
|
|
/// Saves a local model-server configuration.
|
|
pub struct SaveModelServer {
|
|
registry: Arc<dyn ModelServerRegistry>,
|
|
}
|
|
|
|
impl SaveModelServer {
|
|
/// Builds the use case.
|
|
#[must_use]
|
|
pub fn new(registry: Arc<dyn ModelServerRegistry>) -> Self {
|
|
Self { registry }
|
|
}
|
|
|
|
/// Saves a config.
|
|
///
|
|
/// # Errors
|
|
/// [`AppError::ModelServer`] on registry failure.
|
|
pub async fn execute(
|
|
&self,
|
|
input: SaveModelServerInput,
|
|
) -> Result<SaveModelServerOutput, AppError> {
|
|
self.registry.save(input.config.clone()).await?;
|
|
Ok(SaveModelServerOutput {
|
|
config: input.config,
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Input for [`DeleteModelServer::execute`].
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct DeleteModelServerInput {
|
|
/// Config id to delete.
|
|
pub server_id: LocalModelServerId,
|
|
}
|
|
|
|
/// Deletes a local model-server config when no OpenCode profile still references it.
|
|
pub struct DeleteModelServer {
|
|
registry: Arc<dyn ModelServerRegistry>,
|
|
profiles: Arc<dyn ProfileStore>,
|
|
}
|
|
|
|
impl DeleteModelServer {
|
|
/// Builds the use case.
|
|
#[must_use]
|
|
pub fn new(registry: Arc<dyn ModelServerRegistry>, profiles: Arc<dyn ProfileStore>) -> Self {
|
|
Self { registry, profiles }
|
|
}
|
|
|
|
/// Deletes a config after checking profile references.
|
|
///
|
|
/// # Errors
|
|
/// [`AppError::ModelServer`] with `code=model_server_in_use` when referenced.
|
|
pub async fn execute(&self, input: DeleteModelServerInput) -> Result<(), AppError> {
|
|
let profiles = self.profiles.list().await?;
|
|
if profiles.iter().any(|profile| {
|
|
profile
|
|
.opencode
|
|
.as_ref()
|
|
.and_then(|config| config.local_model_server_id)
|
|
== Some(input.server_id)
|
|
}) {
|
|
return Err(ModelServerError::InUse(input.server_id.to_string()).into());
|
|
}
|
|
self.registry.delete(input.server_id).await?;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Input for [`DeleteModelArtifact::execute`].
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct DeleteModelArtifactInput {
|
|
/// Config id whose managed artifact cache should be deleted.
|
|
pub server_id: LocalModelServerId,
|
|
}
|
|
|
|
/// Deletes a downloaded model artifact without deleting the server config.
|
|
pub struct DeleteModelArtifact {
|
|
registry: Arc<dyn ModelServerRegistry>,
|
|
probe: Arc<dyn ModelServerProbe>,
|
|
downloader: Arc<dyn ModelArtifactDownloader>,
|
|
downloads: Arc<dyn ModelArtifactDownloadTracker>,
|
|
profiles: Arc<dyn ProfileStore>,
|
|
projects: Arc<dyn ProjectStore>,
|
|
contexts: Arc<dyn AgentContextStore>,
|
|
live: Arc<dyn LiveAgentRegistry>,
|
|
}
|
|
|
|
impl DeleteModelArtifact {
|
|
/// Builds the use case.
|
|
#[allow(clippy::too_many_arguments)]
|
|
#[must_use]
|
|
pub fn new(
|
|
registry: Arc<dyn ModelServerRegistry>,
|
|
probe: Arc<dyn ModelServerProbe>,
|
|
downloader: Arc<dyn ModelArtifactDownloader>,
|
|
downloads: Arc<dyn ModelArtifactDownloadTracker>,
|
|
profiles: Arc<dyn ProfileStore>,
|
|
projects: Arc<dyn ProjectStore>,
|
|
contexts: Arc<dyn AgentContextStore>,
|
|
live: Arc<dyn LiveAgentRegistry>,
|
|
) -> Self {
|
|
Self {
|
|
registry,
|
|
probe,
|
|
downloader,
|
|
downloads,
|
|
profiles,
|
|
projects,
|
|
contexts,
|
|
live,
|
|
}
|
|
}
|
|
|
|
/// Deletes a managed Hugging Face artifact after safety checks.
|
|
///
|
|
/// # Errors
|
|
/// [`AppError::ModelServer`] when the server is missing, the source is not
|
|
/// deletable, a download is active, or a live agent uses the server.
|
|
pub async fn execute(&self, input: DeleteModelArtifactInput) -> Result<(), AppError> {
|
|
let config = self
|
|
.registry
|
|
.get(&input.server_id)
|
|
.await?
|
|
.ok_or(ModelServerError::NotConfigured)?;
|
|
let Some(ModelSource::HuggingFace { repo }) = config.model.source.as_ref() else {
|
|
return Err(ModelServerError::Invalid(
|
|
"only managed Hugging Face model artifacts can be deleted".to_owned(),
|
|
)
|
|
.into());
|
|
};
|
|
if self
|
|
.downloads
|
|
.is_model_artifact_download_in_progress(input.server_id)
|
|
{
|
|
return Err(ModelServerError::InUse(format!(
|
|
"model artifact download in progress for {}",
|
|
input.server_id
|
|
))
|
|
.into());
|
|
}
|
|
self.ensure_server_not_reachable(&config).await?;
|
|
self.ensure_not_used_by_live_agent(input.server_id).await?;
|
|
self.downloader.delete_hf_model(repo).await?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn ensure_server_not_reachable(
|
|
&self,
|
|
config: &LocalModelServerConfig,
|
|
) -> Result<(), AppError> {
|
|
match self.probe.probe(&config.endpoint).await? {
|
|
ModelServerStatus::Unreachable => Ok(()),
|
|
ModelServerStatus::ReadyReused | ModelServerStatus::ReadyStarted => {
|
|
Err(ModelServerError::InUse(format!(
|
|
"model server {} is currently reachable",
|
|
config.id
|
|
))
|
|
.into())
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn ensure_not_used_by_live_agent(
|
|
&self,
|
|
server_id: LocalModelServerId,
|
|
) -> Result<(), AppError> {
|
|
let profiles = self.profiles.list().await?;
|
|
let profile_server: HashMap<_, _> = profiles
|
|
.iter()
|
|
.filter_map(|profile| {
|
|
profile
|
|
.opencode
|
|
.as_ref()
|
|
.and_then(|opencode| opencode.local_model_server_id)
|
|
.map(|id| (profile.id, id))
|
|
})
|
|
.collect();
|
|
|
|
let mut agents_by_project = HashMap::new();
|
|
for snapshot in self.live.live_agent_snapshots() {
|
|
let agents = if let Some(agents) = agents_by_project.get(&snapshot.project_id) {
|
|
agents
|
|
} else {
|
|
let project = self.projects.load_project(snapshot.project_id).await?;
|
|
let manifest = self.contexts.load_manifest(&project).await?;
|
|
agents_by_project.insert(
|
|
snapshot.project_id,
|
|
manifest
|
|
.entries
|
|
.iter()
|
|
.map(|entry| {
|
|
entry
|
|
.to_agent()
|
|
.map_err(|err| AppError::Invalid(err.to_string()))
|
|
})
|
|
.collect::<Result<Vec<_>, _>>()?,
|
|
);
|
|
agents_by_project
|
|
.get(&snapshot.project_id)
|
|
.expect("project agents inserted")
|
|
};
|
|
let Some(agent) = agents.iter().find(|agent| agent.id == snapshot.agent_id) else {
|
|
continue;
|
|
};
|
|
if profile_server.get(&agent.profile_id) == Some(&server_id) {
|
|
return Err(ModelServerError::InUse(format!(
|
|
"model server {server_id} is used by live agent {}",
|
|
agent.id
|
|
))
|
|
.into());
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Input for [`EnsureLocalModelServer::execute`].
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct EnsureLocalModelServerInput {
|
|
/// Referenced local model server.
|
|
pub server_id: LocalModelServerId,
|
|
}
|
|
|
|
/// Output of [`EnsureLocalModelServer::execute`].
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct EnsureLocalModelServerOutput {
|
|
/// Ready server data to inject into OpenCode config.
|
|
pub ready: ModelServerReady,
|
|
}
|
|
|
|
#[derive(Debug, Default)]
|
|
struct ServerUse {
|
|
refs: usize,
|
|
}
|
|
|
|
/// RAII guard held by live OpenCode sessions while they use a local model server.
|
|
///
|
|
/// Local llama.cpp servers expose a stateless OpenAI-compatible endpoint, so they
|
|
/// may be shared by legitimate OpenCode launches across projects. The guard only
|
|
/// tracks liveness so session removal releases the usage reference.
|
|
#[derive(Debug)]
|
|
pub struct ModelServerUseGuard {
|
|
server_id: LocalModelServerId,
|
|
usages: Arc<Mutex<HashMap<LocalModelServerId, ServerUse>>>,
|
|
}
|
|
|
|
impl Drop for ModelServerUseGuard {
|
|
fn drop(&mut self) {
|
|
let Ok(mut usages) = self.usages.lock() else {
|
|
return;
|
|
};
|
|
let Some(active) = usages.get_mut(&self.server_id) else {
|
|
return;
|
|
};
|
|
active.refs = active.refs.saturating_sub(1);
|
|
if active.refs == 0 {
|
|
usages.remove(&self.server_id);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Readiness retry policy.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub struct ReadinessPolicy {
|
|
/// Number of probes after spawning.
|
|
pub attempts: usize,
|
|
/// Delay between attempts.
|
|
pub backoff: Duration,
|
|
/// Maximum time to wait for an auto-started process to expose its endpoint.
|
|
pub warmup_deadline: Duration,
|
|
}
|
|
|
|
impl Default for ReadinessPolicy {
|
|
fn default() -> Self {
|
|
Self {
|
|
attempts: 20,
|
|
backoff: Duration::from_millis(250),
|
|
warmup_deadline: Duration::from_secs(600),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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,
|
|
port: u16,
|
|
stop_policy: StopPolicy,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct InflightEnsure {
|
|
result: Mutex<Option<Result<EnsureLocalModelServerOutput, AppError>>>,
|
|
notify: Notify,
|
|
}
|
|
|
|
impl InflightEnsure {
|
|
fn new() -> Self {
|
|
Self {
|
|
result: Mutex::new(None),
|
|
notify: Notify::new(),
|
|
}
|
|
}
|
|
|
|
async fn wait(&self) -> Result<EnsureLocalModelServerOutput, AppError> {
|
|
loop {
|
|
if let Some(result) = self.result.lock().unwrap().clone() {
|
|
return result;
|
|
}
|
|
self.notify.notified().await;
|
|
}
|
|
}
|
|
|
|
fn complete(&self, result: Result<EnsureLocalModelServerOutput, AppError>) {
|
|
*self.result.lock().unwrap() = Some(result);
|
|
self.notify.notify_waiters();
|
|
}
|
|
}
|
|
|
|
/// Ensures a configured local model server is reachable, starting it when allowed.
|
|
pub struct EnsureLocalModelServer {
|
|
registry: Arc<dyn ModelServerRegistry>,
|
|
probe: Arc<dyn ModelServerProbe>,
|
|
process: Arc<dyn ManagedProcess>,
|
|
runtime: Arc<dyn ModelServerRuntime>,
|
|
fs: Arc<dyn FileSystem>,
|
|
events: Arc<dyn EventBus>,
|
|
active: Mutex<HashMap<LocalModelServerId, ActiveServer>>,
|
|
inflight: AsyncMutex<HashMap<LocalModelServerId, Arc<InflightEnsure>>>,
|
|
usages: Arc<Mutex<HashMap<LocalModelServerId, ServerUse>>>,
|
|
download_cancels: Mutex<HashMap<LocalModelServerId, ModelArtifactCancel>>,
|
|
readiness: ReadinessPolicy,
|
|
hf_download_deadline: Duration,
|
|
model_artifact_downloader: Option<Arc<dyn ModelArtifactDownloader>>,
|
|
}
|
|
|
|
impl EnsureLocalModelServer {
|
|
/// Builds the use case.
|
|
#[allow(clippy::too_many_arguments)]
|
|
#[must_use]
|
|
pub fn new(
|
|
registry: Arc<dyn ModelServerRegistry>,
|
|
probe: Arc<dyn ModelServerProbe>,
|
|
process: Arc<dyn ManagedProcess>,
|
|
runtime: Arc<dyn ModelServerRuntime>,
|
|
fs: Arc<dyn FileSystem>,
|
|
events: Arc<dyn EventBus>,
|
|
) -> Self {
|
|
Self {
|
|
registry,
|
|
probe,
|
|
process,
|
|
runtime,
|
|
fs,
|
|
events,
|
|
active: Mutex::new(HashMap::new()),
|
|
inflight: AsyncMutex::new(HashMap::new()),
|
|
usages: Arc::new(Mutex::new(HashMap::new())),
|
|
download_cancels: Mutex::new(HashMap::new()),
|
|
readiness: ReadinessPolicy::default(),
|
|
hf_download_deadline: DEFAULT_HF_DOWNLOAD_DEADLINE,
|
|
model_artifact_downloader: None,
|
|
}
|
|
}
|
|
|
|
/// Overrides readiness policy, mainly for tests.
|
|
#[must_use]
|
|
pub fn with_readiness_policy(mut self, readiness: ReadinessPolicy) -> Self {
|
|
self.readiness = readiness;
|
|
self
|
|
}
|
|
|
|
/// Returns the readiness policy effective for a persisted server config.
|
|
///
|
|
/// The config may override only the warmup deadline; probe cadence remains
|
|
/// owned by the application policy.
|
|
#[must_use]
|
|
pub fn effective_readiness_policy(&self, config: &LocalModelServerConfig) -> ReadinessPolicy {
|
|
self.readiness_for(config)
|
|
}
|
|
|
|
/// 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
|
|
}
|
|
|
|
/// 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
|
|
}
|
|
|
|
/// Acquires a live-use reference of `server_id`.
|
|
///
|
|
/// # Errors
|
|
/// [`AppError::ModelServer`] with `code=model_server_in_use` if the usage
|
|
/// tracker lock is poisoned.
|
|
pub fn acquire_use(
|
|
&self,
|
|
server_id: LocalModelServerId,
|
|
) -> Result<ModelServerUseGuard, AppError> {
|
|
let mut usages = self
|
|
.usages
|
|
.lock()
|
|
.map_err(|_| ModelServerError::InUse(server_id.to_string()))?;
|
|
let active = usages.entry(server_id).or_default();
|
|
active.refs = active.refs.saturating_add(1);
|
|
Ok(ModelServerUseGuard {
|
|
server_id,
|
|
usages: Arc::clone(&self.usages),
|
|
})
|
|
}
|
|
|
|
/// Ensures the server is reachable.
|
|
///
|
|
/// # Errors
|
|
/// [`AppError::ModelServer`] if the server cannot be prepared.
|
|
pub async fn execute(
|
|
&self,
|
|
input: EnsureLocalModelServerInput,
|
|
) -> Result<EnsureLocalModelServerOutput, AppError> {
|
|
let server_id = input.server_id;
|
|
let (flight, is_leader) = {
|
|
let mut inflight = self.inflight.lock().await;
|
|
if let Some(flight) = inflight.get(&server_id) {
|
|
(Arc::clone(flight), false)
|
|
} else {
|
|
let flight = Arc::new(InflightEnsure::new());
|
|
inflight.insert(server_id, Arc::clone(&flight));
|
|
(flight, true)
|
|
}
|
|
};
|
|
|
|
if !is_leader {
|
|
return flight.wait().await;
|
|
}
|
|
|
|
let result = self.execute_inner(input).await;
|
|
flight.complete(result.clone());
|
|
let mut inflight = self.inflight.lock().await;
|
|
if inflight
|
|
.get(&server_id)
|
|
.is_some_and(|current| Arc::ptr_eq(current, &flight))
|
|
{
|
|
inflight.remove(&server_id);
|
|
}
|
|
result
|
|
}
|
|
|
|
async fn execute_inner(
|
|
&self,
|
|
input: EnsureLocalModelServerInput,
|
|
) -> Result<EnsureLocalModelServerOutput, AppError> {
|
|
let config = self
|
|
.registry
|
|
.get(&input.server_id)
|
|
.await?
|
|
.ok_or(ModelServerError::NotConfigured)?;
|
|
|
|
self.publish(config.id, ModelServerLifecycleStatus::Probing);
|
|
let initial_probe = match self.probe.probe(&config.endpoint).await {
|
|
Ok(status) => status,
|
|
Err(err) => return self.fail(config.id, err),
|
|
};
|
|
match initial_probe {
|
|
ModelServerStatus::ReadyReused | ModelServerStatus::ReadyStarted => {
|
|
self.publish(
|
|
config.id,
|
|
ModelServerLifecycleStatus::Ready { reused: true },
|
|
);
|
|
return Ok(EnsureLocalModelServerOutput {
|
|
ready: ready(&config, ModelServerStatus::ReadyReused),
|
|
});
|
|
}
|
|
ModelServerStatus::Unreachable => {}
|
|
}
|
|
|
|
if !config.auto_start {
|
|
let err = ModelServerError::Probe("server unreachable and autoStart=false".to_owned());
|
|
self.publish_failure(config.id, &err);
|
|
return Err(err.into());
|
|
}
|
|
|
|
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(&spawn_config) {
|
|
Ok(spec) => spec,
|
|
Err(err) => return self.fail(config.id, err),
|
|
};
|
|
let handle = match self.process.spawn(spec).await {
|
|
Ok(handle) => handle,
|
|
Err(err) => return self.fail(config.id, err),
|
|
};
|
|
self.active.lock().unwrap().insert(
|
|
config.id,
|
|
ActiveServer {
|
|
handle: handle.clone(),
|
|
port: spawn_config.endpoint.port,
|
|
stop_policy: spawn_config.stop_policy,
|
|
},
|
|
);
|
|
|
|
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,
|
|
handle: &ManagedProcessHandle,
|
|
hf_source: Option<String>,
|
|
) -> Result<EnsureLocalModelServerOutput, AppError> {
|
|
let readiness = self.readiness_for(config);
|
|
let mut attempts = 0usize;
|
|
let deadline = Instant::now() + readiness.warmup_deadline;
|
|
loop {
|
|
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 attempts.saturating_add(1) == readiness.attempts {
|
|
if let Some(source) = hf_source.as_ref() {
|
|
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),
|
|
}
|
|
|
|
attempts = attempts.saturating_add(1);
|
|
if Instant::now() >= deadline {
|
|
let err = ModelServerError::Timeout;
|
|
self.stop_started_server(config.id, handle).await;
|
|
return self.fail(config.id, err);
|
|
}
|
|
if !readiness.backoff.is_zero() {
|
|
tokio::time::sleep(readiness.backoff).await;
|
|
} else {
|
|
tokio::task::yield_now().await;
|
|
}
|
|
}
|
|
}
|
|
|
|
fn readiness_for(&self, config: &LocalModelServerConfig) -> ReadinessPolicy {
|
|
ReadinessPolicy {
|
|
warmup_deadline: config
|
|
.warmup_deadline_secs
|
|
.map(Duration::from_secs)
|
|
.unwrap_or(self.readiness.warmup_deadline),
|
|
..self.readiness
|
|
}
|
|
}
|
|
|
|
/// Stops active servers whose policy is [`StopPolicy::StopOnAppExit`].
|
|
///
|
|
/// # 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()
|
|
.unwrap()
|
|
.iter()
|
|
.map(|(id, active)| (*id, active.clone()))
|
|
.collect();
|
|
let mut first_error: Option<ModelServerError> = None;
|
|
for (id, active) in entries {
|
|
if active.stop_policy != StopPolicy::StopOnAppExit {
|
|
continue;
|
|
}
|
|
if let Err(err) = self.process.kill(&active.handle).await {
|
|
if first_error.is_none() {
|
|
first_error = Some(err);
|
|
}
|
|
} else {
|
|
self.active.lock().unwrap().remove(&id);
|
|
}
|
|
}
|
|
if let Some(err) = first_error {
|
|
Err(err.into())
|
|
} else {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
async fn ensure_model_path_accessible(
|
|
&self,
|
|
config: &LocalModelServerConfig,
|
|
) -> Result<(), AppError> {
|
|
let Some(ModelSource::LocalPath { path }) = config.model.source.as_ref() else {
|
|
return Ok(());
|
|
};
|
|
if path.as_str().is_empty() {
|
|
let err = ModelServerError::PathNotAccessible("model.source.path missing".to_owned());
|
|
self.publish_failure(config.id, &err);
|
|
return Err(err.into());
|
|
}
|
|
match self
|
|
.fs
|
|
.exists(&RemotePath::new(path.as_str().to_owned()))
|
|
.await
|
|
{
|
|
Ok(true) => Ok(()),
|
|
Ok(false) => {
|
|
let err = ModelServerError::PathNotAccessible(path.as_str().to_owned());
|
|
self.publish_failure(config.id, &err);
|
|
Err(err.into())
|
|
}
|
|
Err(domain::ports::FsError::PermissionDenied(p)) => {
|
|
let err = ModelServerError::PermissionDenied(p);
|
|
self.publish_failure(config.id, &err);
|
|
Err(err.into())
|
|
}
|
|
Err(err) => {
|
|
let err = ModelServerError::PathNotAccessible(err.to_string());
|
|
self.publish_failure(config.id, &err);
|
|
Err(err.into())
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn ensure_no_active_port_collision(
|
|
&self,
|
|
config: &LocalModelServerConfig,
|
|
) -> Result<(), AppError> {
|
|
let active: Vec<(LocalModelServerId, ActiveServer)> = self
|
|
.active
|
|
.lock()
|
|
.unwrap()
|
|
.iter()
|
|
.map(|(id, active)| (*id, active.clone()))
|
|
.collect();
|
|
for (id, active) in active {
|
|
if active.port != config.endpoint.port {
|
|
continue;
|
|
}
|
|
match self.process.status(&active.handle).await {
|
|
Ok(ProcessStatus::Running) => {
|
|
let err = ModelServerError::PortOccupied(config.endpoint.port);
|
|
self.publish_failure(config.id, &err);
|
|
return Err(err.into());
|
|
}
|
|
Ok(ProcessStatus::Exited { .. } | ProcessStatus::Unknown) => {
|
|
self.active.lock().unwrap().remove(&id);
|
|
}
|
|
Err(err) => return self.fail(config.id, err),
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn stop_started_server(
|
|
&self,
|
|
server_id: LocalModelServerId,
|
|
handle: &ManagedProcessHandle,
|
|
) {
|
|
let _ = self.process.kill(handle).await;
|
|
self.active.lock().unwrap().remove(&server_id);
|
|
}
|
|
|
|
fn publish(&self, server_id: LocalModelServerId, status: ModelServerLifecycleStatus) {
|
|
self.events
|
|
.publish(DomainEvent::ModelServerStatusChanged { server_id, status });
|
|
}
|
|
|
|
fn fail<T>(&self, server_id: LocalModelServerId, err: ModelServerError) -> Result<T, AppError> {
|
|
self.publish_failure(server_id, &err);
|
|
Err(err.into())
|
|
}
|
|
|
|
fn publish_failure(&self, server_id: LocalModelServerId, err: &ModelServerError) {
|
|
self.publish(
|
|
server_id,
|
|
ModelServerLifecycleStatus::Failed {
|
|
code: model_server_error_code(err).to_owned(),
|
|
message: err.to_string(),
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
impl ModelArtifactDownloadTracker for EnsureLocalModelServer {
|
|
fn is_model_artifact_download_in_progress(&self, server_id: LocalModelServerId) -> bool {
|
|
self.download_cancels
|
|
.lock()
|
|
.unwrap()
|
|
.contains_key(&server_id)
|
|
}
|
|
}
|
|
|
|
fn ready(config: &LocalModelServerConfig, status: ModelServerStatus) -> ModelServerReady {
|
|
ModelServerReady {
|
|
base_url: config.endpoint.base_url.clone(),
|
|
model: config.model.served_name.clone(),
|
|
status,
|
|
}
|
|
}
|
|
|
|
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 {
|
|
match err {
|
|
ModelServerError::NotConfigured => "not_configured",
|
|
ModelServerError::Invalid(_) => "invalid",
|
|
ModelServerError::PermissionDenied(_) => "permission_denied",
|
|
ModelServerError::PathNotAccessible(_) => "path_not_accessible",
|
|
ModelServerError::PortOccupied(_) => "port_occupied",
|
|
ModelServerError::InUse(_) => "model_server_in_use",
|
|
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
|
|
)
|
|
}
|