- #70: implémentation suppression modèles locaux téléchargés - #100: correction scroll OpenCode - #102: correction fit TUI après switch/layout - memory note scoping UX
934 lines
31 KiB
Rust
934 lines
31 KiB
Rust
//! Infrastructure adapters for local model servers.
|
|
|
|
use std::collections::HashMap;
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::{Arc, 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 tokio::sync::Mutex as AsyncMutex;
|
|
|
|
use domain::model_server::{
|
|
ExecutablePath, HfModelRef, LlamaCppOptions, LocalModelRef, LocalModelServerConfig,
|
|
LocalModelServerKind, ModelPath, ModelServerEndpoint, ModelSource,
|
|
};
|
|
use domain::ports::{
|
|
FileSystem, ManagedProcess, ManagedProcessHandle, ModelArtifactCancel, ModelArtifactDownloader,
|
|
ModelArtifactProgress, ModelArtifactResolution, ModelArtifactState, ModelServerArgv,
|
|
ModelServerError, ModelServerProbe, ModelServerRegistry, ModelServerRuntime, ProcessStatus,
|
|
RemotePath, SpawnSpec,
|
|
};
|
|
use domain::{LocalModelServerId, ProjectPath, StopPolicy};
|
|
|
|
/// HTTP readiness probe for OpenAI-compatible servers.
|
|
#[derive(Clone)]
|
|
pub struct HttpOpenAiCompatibleProbe {
|
|
client: reqwest::Client,
|
|
}
|
|
|
|
impl Default for HttpOpenAiCompatibleProbe {
|
|
fn default() -> Self {
|
|
Self::new(Duration::from_secs(2))
|
|
}
|
|
}
|
|
|
|
impl HttpOpenAiCompatibleProbe {
|
|
/// Builds the probe with a request timeout.
|
|
#[must_use]
|
|
pub fn new(timeout: Duration) -> Self {
|
|
let client = reqwest::Client::builder()
|
|
.timeout(timeout)
|
|
.build()
|
|
.expect("reqwest client with timeout builds");
|
|
Self { client }
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl ModelServerProbe for HttpOpenAiCompatibleProbe {
|
|
async fn probe(
|
|
&self,
|
|
endpoint: &ModelServerEndpoint,
|
|
) -> Result<domain::ModelServerStatus, ModelServerError> {
|
|
let models = format!("{}/models", endpoint.base_url.trim_end_matches('/'));
|
|
if is_ready(self.client.get(models).send().await) {
|
|
return Ok(domain::ModelServerStatus::ReadyReused);
|
|
}
|
|
let root = endpoint.base_url.trim_end_matches("/v1").to_owned();
|
|
if is_ready(self.client.get(root).send().await) {
|
|
return Ok(domain::ModelServerStatus::ReadyReused);
|
|
}
|
|
Ok(domain::ModelServerStatus::Unreachable)
|
|
}
|
|
}
|
|
|
|
fn is_ready(result: Result<reqwest::Response, reqwest::Error>) -> bool {
|
|
result
|
|
.map(|response| response.status().is_success())
|
|
.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,
|
|
repo_locks: Arc<Mutex<HashMap<String, Arc<AsyncMutex<()>>>>>,
|
|
}
|
|
|
|
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(),
|
|
repo_locks: Arc::new(Mutex::new(HashMap::new())),
|
|
}
|
|
}
|
|
|
|
/// Deterministic cache path for a Hugging Face model reference.
|
|
///
|
|
/// This is the local path used for a single **merged** (non-sharded) GGUF file, renamed to a
|
|
/// synthetic `{quant}.gguf` name. Sharded files are never renamed this way: llama.cpp
|
|
/// discovers multi-part siblings by pattern-matching the *original* HF filename in the same
|
|
/// directory, so shard files keep their remote name (see `local_path_for_filename`).
|
|
#[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)))
|
|
}
|
|
|
|
fn cache_dir_for(&self, repo: &HfModelRef) -> PathBuf {
|
|
let (base, _) = split_hf_ref(repo);
|
|
self.cache_dir.join(base.replace('/', "--"))
|
|
}
|
|
|
|
/// Local path for a remote filename that is part of a multi-shard set: the original name is
|
|
/// preserved (sanitized) so llama.cpp's shard autoload can find siblings by pattern.
|
|
fn local_path_for_shard(&self, repo: &HfModelRef, remote_filename: &str) -> PathBuf {
|
|
self.cache_dir_for(repo)
|
|
.join(safe_cache_component(remote_filename))
|
|
}
|
|
|
|
fn manifest_path_for(&self, repo: &HfModelRef) -> PathBuf {
|
|
let (_, quant) = split_hf_ref(repo);
|
|
let file_stem = quant.unwrap_or("model");
|
|
self.cache_dir_for(repo)
|
|
.join(format!("{}.manifest.json", safe_cache_component(file_stem)))
|
|
}
|
|
|
|
/// Reads a manifest of a previously-downloaded shard set, if present and complete on disk.
|
|
fn cached_shard_set(&self, repo: &HfModelRef) -> Option<Vec<PathBuf>> {
|
|
let manifest_path = self.manifest_path_for(repo);
|
|
let raw = std::fs::read(&manifest_path).ok()?;
|
|
let manifest: ShardManifest = serde_json::from_slice(&raw).ok()?;
|
|
let dir = self.cache_dir_for(repo);
|
|
let paths: Vec<PathBuf> = manifest
|
|
.files
|
|
.iter()
|
|
.map(|filename| dir.join(filename))
|
|
.collect();
|
|
if paths.iter().all(|path| path.is_file()) {
|
|
Some(paths)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
fn write_shard_manifest(&self, repo: &HfModelRef, filenames: &[String]) -> std::io::Result<()> {
|
|
let manifest_path = self.manifest_path_for(repo);
|
|
let manifest = ShardManifest {
|
|
files: filenames.to_vec(),
|
|
};
|
|
let json = serde_json::to_vec(&manifest)?;
|
|
std::fs::write(manifest_path, json)
|
|
}
|
|
|
|
fn lock_for(&self, repo: &HfModelRef) -> Arc<AsyncMutex<()>> {
|
|
let mut locks = self.repo_locks.lock().expect("repo locks mutex poisoned");
|
|
Arc::clone(
|
|
locks
|
|
.entry(repo.as_str().to_owned())
|
|
.or_insert_with(|| Arc::new(AsyncMutex::new(()))),
|
|
)
|
|
}
|
|
|
|
fn cached_state(&self, repo: &HfModelRef) -> Result<ModelArtifactState, ModelServerError> {
|
|
let merged_path = self.cache_path_for(repo);
|
|
if merged_path.is_file() {
|
|
return Ok(ModelArtifactState::Downloaded {
|
|
size_bytes: Some(file_size(&merged_path)?),
|
|
path: model_path_from_pathbuf(merged_path)?,
|
|
});
|
|
}
|
|
if let Some(paths) = self.cached_shard_set(repo) {
|
|
if let Some(first) = paths.first() {
|
|
return Ok(ModelArtifactState::Downloaded {
|
|
size_bytes: Some(paths_size(&paths)?),
|
|
path: model_path_from_pathbuf(first.clone())?,
|
|
});
|
|
}
|
|
}
|
|
Ok(ModelArtifactState::Missing)
|
|
}
|
|
|
|
fn delete_cached(&self, repo: &HfModelRef) -> Result<(), ModelServerError> {
|
|
let merged_path = self.cache_path_for(repo);
|
|
if merged_path.is_file() {
|
|
remove_file_if_exists(&merged_path)?;
|
|
}
|
|
|
|
let manifest_path = self.manifest_path_for(repo);
|
|
if let Ok(raw) = std::fs::read(&manifest_path) {
|
|
let manifest: ShardManifest =
|
|
serde_json::from_slice(&raw).map_err(|e| ModelServerError::Store(e.to_string()))?;
|
|
let dir = self.cache_dir_for(repo);
|
|
for filename in manifest.files {
|
|
remove_file_if_exists(&dir.join(filename))?;
|
|
}
|
|
remove_file_if_exists(&manifest_path)?;
|
|
}
|
|
|
|
let repo_dir = self.cache_dir_for(repo);
|
|
if repo_dir.is_dir()
|
|
&& std::fs::read_dir(&repo_dir)
|
|
.map(is_empty_dir)
|
|
.unwrap_or(false)
|
|
{
|
|
std::fs::remove_dir(&repo_dir).map_err(|e| ModelServerError::Store(e.to_string()))?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn resolve_remote_filenames(
|
|
&self,
|
|
repo: &HfModelRef,
|
|
) -> Result<Vec<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()))?;
|
|
let files = select_gguf_files(&metadata.siblings, quant);
|
|
if files.is_empty() {
|
|
return Err(ModelServerError::PathNotAccessible(format!(
|
|
"no matching .gguf artifact found for {}",
|
|
repo.as_str()
|
|
)));
|
|
}
|
|
Ok(files)
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Default, Serialize, Deserialize)]
|
|
struct ShardManifest {
|
|
files: Vec<String>,
|
|
}
|
|
|
|
#[async_trait]
|
|
impl ModelArtifactDownloader for HfModelArtifactDownloader {
|
|
async fn hf_model_state(
|
|
&self,
|
|
repo: &HfModelRef,
|
|
) -> Result<ModelArtifactState, ModelServerError> {
|
|
let lock = self.lock_for(repo);
|
|
let _guard = lock.lock().await;
|
|
self.cached_state(repo)
|
|
}
|
|
|
|
async fn resolve_hf_model(
|
|
&self,
|
|
repo: &HfModelRef,
|
|
progress: std::sync::Arc<dyn Fn(ModelArtifactProgress) + Send + Sync>,
|
|
cancel: ModelArtifactCancel,
|
|
) -> Result<ModelArtifactResolution, ModelServerError> {
|
|
let lock = self.lock_for(repo);
|
|
let _guard = lock.lock().await;
|
|
if cancel.is_cancelled() {
|
|
return Err(ModelServerError::Cancelled);
|
|
}
|
|
|
|
// Fast path 1: a single merged file was already downloaded (synthetic name), no network needed.
|
|
let merged_path = self.cache_path_for(repo);
|
|
if merged_path.is_file() {
|
|
return Ok(ModelArtifactResolution {
|
|
path: model_path_from_pathbuf(merged_path)?,
|
|
cache_hit: true,
|
|
});
|
|
}
|
|
// Fast path 2: a previously-downloaded shard set is fully present on disk, no network needed.
|
|
if let Some(paths) = self.cached_shard_set(repo) {
|
|
if let Some(first) = paths.into_iter().next() {
|
|
return Ok(ModelArtifactResolution {
|
|
path: model_path_from_pathbuf(first)?,
|
|
cache_hit: true,
|
|
});
|
|
}
|
|
}
|
|
|
|
let filenames = self.resolve_remote_filenames(repo).await?;
|
|
if cancel.is_cancelled() {
|
|
return Err(ModelServerError::Cancelled);
|
|
}
|
|
let is_shard_set = filenames.len() > 1;
|
|
let targets: Vec<PathBuf> = if is_shard_set {
|
|
filenames
|
|
.iter()
|
|
.map(|filename| self.local_path_for_shard(repo, filename))
|
|
.collect()
|
|
} else {
|
|
vec![merged_path.clone()]
|
|
};
|
|
|
|
let (base, _) = split_hf_ref(repo);
|
|
let total_files = filenames.len() as u64;
|
|
for (index, (filename, target)) in filenames.iter().zip(targets.iter()).enumerate() {
|
|
if cancel.is_cancelled() {
|
|
return Err(ModelServerError::Cancelled);
|
|
}
|
|
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) = target.parent() {
|
|
tokio::fs::create_dir_all(parent)
|
|
.await
|
|
.map_err(|e| ModelServerError::Store(e.to_string()))?;
|
|
}
|
|
let tmp_path = PathBuf::from(format!("{}.part", target.to_string_lossy()));
|
|
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 + index as u64 * total.unwrap_or(0)),
|
|
total_bytes: total.map(|t| t * total_files),
|
|
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, target)
|
|
.await
|
|
.map_err(|e| ModelServerError::Store(e.to_string()))?;
|
|
}
|
|
|
|
if is_shard_set {
|
|
self.write_shard_manifest(repo, &filenames)
|
|
.map_err(|e| ModelServerError::Store(e.to_string()))?;
|
|
}
|
|
|
|
Ok(ModelArtifactResolution {
|
|
path: model_path_from_pathbuf(targets[0].clone())?,
|
|
cache_hit: false,
|
|
})
|
|
}
|
|
|
|
async fn delete_hf_model(&self, repo: &HfModelRef) -> Result<(), ModelServerError> {
|
|
let lock = self.lock_for(repo);
|
|
let _guard = lock.lock().await;
|
|
self.delete_cached(repo)
|
|
}
|
|
}
|
|
|
|
#[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)))
|
|
}
|
|
|
|
/// Matches the llama.cpp / HF multi-part GGUF naming convention, e.g.
|
|
/// `model-q5_k_m-00001-of-00002.gguf`. Captures the shard index and total shard count.
|
|
fn shard_suffix() -> &'static regex::Regex {
|
|
static RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
|
|
RE.get_or_init(|| regex::Regex::new(r"-(\d{5})-of-(\d{5})\.gguf$").expect("valid regex"))
|
|
}
|
|
|
|
fn shard_index(filename: &str) -> Option<u32> {
|
|
shard_suffix()
|
|
.captures(filename)
|
|
.and_then(|caps| caps.get(1))
|
|
.and_then(|m| m.as_str().parse().ok())
|
|
}
|
|
|
|
/// Resolves the ordered set of remote filenames to download for a quantization: the merged
|
|
/// (non-sharded) file if one matches, otherwise all matching shards sorted by index. A merged
|
|
/// file always takes priority over a shard set when both exist in the repo, since it is what
|
|
/// `llama-server --model` expects and a shard set would otherwise be picked first by naive
|
|
/// alphabetical sort ('-' sorts before '.' in ASCII).
|
|
fn select_gguf_files(siblings: &[HfSibling], quant: Option<&str>) -> Vec<String> {
|
|
let ggufs: Vec<&str> = siblings
|
|
.iter()
|
|
.map(|sibling| sibling.filename.as_str())
|
|
.filter(|filename| filename.ends_with(".gguf"))
|
|
.collect();
|
|
|
|
let matching: Vec<&str> = match quant {
|
|
None => ggufs,
|
|
Some(quant) => {
|
|
let quant = quant.to_ascii_lowercase();
|
|
ggufs
|
|
.into_iter()
|
|
.filter(|filename| filename.to_ascii_lowercase().contains(&quant))
|
|
.collect()
|
|
}
|
|
};
|
|
|
|
let (mut merged, mut shards): (Vec<&str>, Vec<&str>) = (Vec::new(), Vec::new());
|
|
for filename in matching {
|
|
if shard_index(filename).is_some() {
|
|
shards.push(filename);
|
|
} else {
|
|
merged.push(filename);
|
|
}
|
|
}
|
|
|
|
if let Some(first_merged) = {
|
|
merged.sort_unstable();
|
|
merged.first()
|
|
} {
|
|
return vec![(*first_merged).to_owned()];
|
|
}
|
|
|
|
shards.sort_unstable_by_key(|filename| shard_index(filename).unwrap_or(u32::MAX));
|
|
shards.into_iter().map(str::to_owned).collect()
|
|
}
|
|
|
|
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()))
|
|
}
|
|
|
|
fn file_size(path: &Path) -> Result<u64, ModelServerError> {
|
|
std::fs::metadata(path)
|
|
.map(|metadata| metadata.len())
|
|
.map_err(|e| ModelServerError::Store(e.to_string()))
|
|
}
|
|
|
|
fn paths_size(paths: &[PathBuf]) -> Result<u64, ModelServerError> {
|
|
paths
|
|
.iter()
|
|
.map(|path| file_size(path))
|
|
.try_fold(0_u64, |acc, size| size.map(|size| acc.saturating_add(size)))
|
|
}
|
|
|
|
fn remove_file_if_exists(path: &Path) -> Result<(), ModelServerError> {
|
|
match std::fs::remove_file(path) {
|
|
Ok(()) => Ok(()),
|
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
|
Err(err) => Err(ModelServerError::Store(err.to_string())),
|
|
}
|
|
}
|
|
|
|
fn is_empty_dir(entries: std::fs::ReadDir) -> bool {
|
|
entries.into_iter().next().is_none()
|
|
}
|
|
|
|
/// Builds `llama-server` argv without shell interpolation.
|
|
#[derive(Debug, Default, Clone, Copy)]
|
|
pub struct LlamaCppRuntime;
|
|
|
|
impl LlamaCppRuntime {
|
|
/// Creates the runtime.
|
|
#[must_use]
|
|
pub const fn new() -> Self {
|
|
Self
|
|
}
|
|
}
|
|
|
|
impl ModelServerRuntime for LlamaCppRuntime {
|
|
fn build_argv(
|
|
&self,
|
|
config: &LocalModelServerConfig,
|
|
) -> Result<ModelServerArgv, ModelServerError> {
|
|
if config.kind != LocalModelServerKind::LlamaCpp {
|
|
return Err(ModelServerError::Invalid(
|
|
"only LlamaCpp local model servers are supported".to_owned(),
|
|
));
|
|
}
|
|
let command = resolve_binary(
|
|
config
|
|
.binary
|
|
.as_ref()
|
|
.map(|binary| binary.as_str())
|
|
.unwrap_or("llama-server"),
|
|
)?;
|
|
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(),
|
|
]);
|
|
if let Some(gpu_layers) = config.options.gpu_layers {
|
|
args.extend(["-ngl".to_owned(), gpu_layers.to_string()]);
|
|
}
|
|
if let Some(context_size) = config.options.context_size {
|
|
args.extend(["-c".to_owned(), context_size.to_string()]);
|
|
}
|
|
if config.options.jinja {
|
|
args.push("--jinja".to_owned());
|
|
}
|
|
args.extend(config.args.clone());
|
|
Ok(ModelServerArgv { command, args })
|
|
}
|
|
|
|
fn build_spawn_spec(
|
|
&self,
|
|
config: &LocalModelServerConfig,
|
|
) -> Result<SpawnSpec, ModelServerError> {
|
|
let argv = self.build_argv(config)?;
|
|
Ok(SpawnSpec {
|
|
command: argv.command,
|
|
args: argv.args,
|
|
cwd: ProjectPath::new("/").map_err(|e| ModelServerError::Invalid(e.to_string()))?,
|
|
env: Vec::new(),
|
|
context_plan: None,
|
|
sandbox: None,
|
|
})
|
|
}
|
|
}
|
|
|
|
fn resolve_binary(raw: &str) -> Result<String, ModelServerError> {
|
|
if is_path_like(raw) {
|
|
let path = Path::new(raw);
|
|
if path.is_file() {
|
|
return Ok(raw.to_owned());
|
|
}
|
|
return Err(ModelServerError::PathNotAccessible(raw.to_owned()));
|
|
}
|
|
if let Some(path) = find_in_path(raw) {
|
|
return Ok(path.to_string_lossy().into_owned());
|
|
}
|
|
Err(ModelServerError::PathNotAccessible(format!(
|
|
"{raw} not found in PATH"
|
|
)))
|
|
}
|
|
|
|
fn is_path_like(raw: &str) -> bool {
|
|
raw.contains('/') || raw.contains('\\') || Path::new(raw).is_absolute()
|
|
}
|
|
|
|
fn find_in_path(command: &str) -> Option<PathBuf> {
|
|
std::env::var_os("PATH").and_then(|path| {
|
|
std::env::split_paths(&path)
|
|
.map(|dir| dir.join(command))
|
|
.find(|candidate| candidate.is_file())
|
|
})
|
|
}
|
|
|
|
/// Local child-process manager for long-lived model servers.
|
|
#[derive(Default)]
|
|
pub struct LocalManagedProcess {
|
|
children: Mutex<HashMap<String, Child>>,
|
|
}
|
|
|
|
impl LocalManagedProcess {
|
|
/// Creates an empty process manager.
|
|
#[must_use]
|
|
pub fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl ManagedProcess for LocalManagedProcess {
|
|
async fn spawn(&self, spec: SpawnSpec) -> Result<ManagedProcessHandle, ModelServerError> {
|
|
let mut command = Command::new(&spec.command);
|
|
command.args(&spec.args);
|
|
if spec.cwd.as_str() != "/" {
|
|
command.current_dir(spec.cwd.as_str());
|
|
}
|
|
for (key, value) in &spec.env {
|
|
command.env(key, value);
|
|
}
|
|
let child = command
|
|
.spawn()
|
|
.map_err(|e| ModelServerError::Process(format!("{}: {e}", spec.command)))?;
|
|
let id = uuid::Uuid::new_v4().to_string();
|
|
self.children.lock().unwrap().insert(id.clone(), child);
|
|
Ok(ManagedProcessHandle { id })
|
|
}
|
|
|
|
async fn kill(&self, handle: &ManagedProcessHandle) -> Result<(), ModelServerError> {
|
|
let Some(mut child) = self.children.lock().unwrap().remove(&handle.id) else {
|
|
return Ok(());
|
|
};
|
|
child
|
|
.start_kill()
|
|
.map_err(|e| ModelServerError::Process(e.to_string()))
|
|
}
|
|
|
|
async fn status(
|
|
&self,
|
|
handle: &ManagedProcessHandle,
|
|
) -> Result<ProcessStatus, ModelServerError> {
|
|
let mut children = self.children.lock().unwrap();
|
|
let Some(child) = children.get_mut(&handle.id) else {
|
|
return Ok(ProcessStatus::Unknown);
|
|
};
|
|
match child
|
|
.try_wait()
|
|
.map_err(|e| ModelServerError::Process(e.to_string()))?
|
|
{
|
|
Some(status) => Ok(ProcessStatus::Exited {
|
|
code: status.code(),
|
|
}),
|
|
None => Ok(ProcessStatus::Running),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// File name of the global local-model-server registry.
|
|
const MODEL_SERVERS_FILE: &str = "model-servers.json";
|
|
const MODEL_SERVERS_VERSION: u32 = 2;
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct ModelServersDoc {
|
|
version: u32,
|
|
servers: Vec<LocalModelServerConfig>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
#[serde(untagged)]
|
|
enum PersistedModelServersDoc {
|
|
V2(ModelServersDoc),
|
|
V1(ModelServersDocV1),
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct ModelServersDocV1 {
|
|
servers: Vec<LocalModelServerConfigV1>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct LocalModelServerConfigV1 {
|
|
id: LocalModelServerId,
|
|
kind: LocalModelServerKind,
|
|
name: String,
|
|
endpoint: ModelServerEndpoint,
|
|
model: LocalModelRefV1,
|
|
#[serde(default)]
|
|
binary: Option<ExecutablePath>,
|
|
#[serde(default)]
|
|
args: Vec<String>,
|
|
auto_start: bool,
|
|
#[serde(default = "default_stop_policy")]
|
|
stop_policy: StopPolicy,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct LocalModelRefV1 {
|
|
id: String,
|
|
label: String,
|
|
#[serde(default)]
|
|
path: Option<ModelPath>,
|
|
served_name: String,
|
|
}
|
|
|
|
fn default_stop_policy() -> StopPolicy {
|
|
StopPolicy::StopOnAppExit
|
|
}
|
|
|
|
impl From<ModelServersDocV1> for ModelServersDoc {
|
|
fn from(doc: ModelServersDocV1) -> Self {
|
|
Self {
|
|
version: MODEL_SERVERS_VERSION,
|
|
servers: doc
|
|
.servers
|
|
.into_iter()
|
|
.map(|server| LocalModelServerConfig {
|
|
id: server.id,
|
|
kind: server.kind,
|
|
name: server.name,
|
|
endpoint: server.endpoint,
|
|
model: LocalModelRef {
|
|
id: server.model.id,
|
|
label: server.model.label,
|
|
source: server
|
|
.model
|
|
.path
|
|
.map(|path| ModelSource::LocalPath { path }),
|
|
served_name: server.model.served_name,
|
|
},
|
|
binary: server.binary,
|
|
options: LlamaCppOptions::default(),
|
|
args: server.args,
|
|
auto_start: server.auto_start,
|
|
stop_policy: server.stop_policy,
|
|
warmup_deadline_secs: None,
|
|
})
|
|
.collect(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Default for ModelServersDoc {
|
|
fn default() -> Self {
|
|
Self {
|
|
version: MODEL_SERVERS_VERSION,
|
|
servers: Vec::new(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Filesystem-backed global model-server registry.
|
|
#[derive(Clone)]
|
|
pub struct FsModelServerRegistry {
|
|
fs: std::sync::Arc<dyn FileSystem>,
|
|
app_data_dir: String,
|
|
}
|
|
|
|
impl FsModelServerRegistry {
|
|
/// Builds the registry from a filesystem and global app-data dir.
|
|
#[must_use]
|
|
pub fn new(fs: std::sync::Arc<dyn FileSystem>, app_data_dir: impl Into<String>) -> Self {
|
|
Self {
|
|
fs,
|
|
app_data_dir: app_data_dir.into(),
|
|
}
|
|
}
|
|
|
|
fn path(&self) -> RemotePath {
|
|
let base = self.app_data_dir.trim_end_matches(['/', '\\']);
|
|
RemotePath::new(format!("{base}/{MODEL_SERVERS_FILE}"))
|
|
}
|
|
|
|
async fn read_doc(&self) -> Result<ModelServersDoc, ModelServerError> {
|
|
match self.fs.read(&self.path()).await {
|
|
Ok(bytes) => match serde_json::from_slice::<PersistedModelServersDoc>(&bytes)
|
|
.map_err(|e| ModelServerError::Store(format!("serialization failed: {e}")))?
|
|
{
|
|
PersistedModelServersDoc::V2(doc) => Ok(ModelServersDoc {
|
|
version: MODEL_SERVERS_VERSION,
|
|
servers: doc.servers,
|
|
}),
|
|
PersistedModelServersDoc::V1(doc) => Ok(doc.into()),
|
|
},
|
|
Err(domain::ports::FsError::NotFound(_)) => Ok(ModelServersDoc::default()),
|
|
Err(domain::ports::FsError::PermissionDenied(p)) => {
|
|
Err(ModelServerError::PermissionDenied(p))
|
|
}
|
|
Err(e) => Err(ModelServerError::Store(e.to_string())),
|
|
}
|
|
}
|
|
|
|
async fn write_doc(&self, doc: &ModelServersDoc) -> Result<(), ModelServerError> {
|
|
let dir = RemotePath::new(self.app_data_dir.trim_end_matches(['/', '\\']).to_owned());
|
|
self.fs.create_dir_all(&dir).await.map_err(|e| match e {
|
|
domain::ports::FsError::PermissionDenied(p) => ModelServerError::PermissionDenied(p),
|
|
other => ModelServerError::Store(other.to_string()),
|
|
})?;
|
|
let bytes = serde_json::to_vec_pretty(doc)
|
|
.map_err(|e| ModelServerError::Store(format!("serialization failed: {e}")))?;
|
|
self.fs
|
|
.write(&self.path(), &bytes)
|
|
.await
|
|
.map_err(|e| match e {
|
|
domain::ports::FsError::PermissionDenied(p) => {
|
|
ModelServerError::PermissionDenied(p)
|
|
}
|
|
other => ModelServerError::Store(other.to_string()),
|
|
})
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl ModelServerRegistry for FsModelServerRegistry {
|
|
async fn get(
|
|
&self,
|
|
id: &LocalModelServerId,
|
|
) -> Result<Option<LocalModelServerConfig>, ModelServerError> {
|
|
Ok(self
|
|
.read_doc()
|
|
.await?
|
|
.servers
|
|
.into_iter()
|
|
.find(|server| &server.id == id))
|
|
}
|
|
|
|
async fn list(&self) -> Result<Vec<LocalModelServerConfig>, ModelServerError> {
|
|
Ok(self.read_doc().await?.servers)
|
|
}
|
|
|
|
async fn save(&self, config: LocalModelServerConfig) -> Result<(), ModelServerError> {
|
|
config
|
|
.options
|
|
.validate()
|
|
.map_err(|e| ModelServerError::Invalid(e.to_string()))?;
|
|
domain::model_server::validate_free_args(&config.args)
|
|
.map_err(|e| ModelServerError::Invalid(e.to_string()))?;
|
|
let mut doc = self.read_doc().await?;
|
|
doc.version = MODEL_SERVERS_VERSION;
|
|
if let Some(slot) = doc.servers.iter_mut().find(|server| server.id == config.id) {
|
|
*slot = config;
|
|
} else {
|
|
doc.servers.push(config);
|
|
}
|
|
self.write_doc(&doc).await
|
|
}
|
|
|
|
async fn delete(&self, id: LocalModelServerId) -> Result<(), ModelServerError> {
|
|
let mut doc = self.read_doc().await?;
|
|
doc.servers.retain(|server| server.id != id);
|
|
self.write_doc(&doc).await
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod gguf_selection_tests {
|
|
use super::{select_gguf_files, HfSibling};
|
|
|
|
fn sibling(name: &str) -> HfSibling {
|
|
HfSibling {
|
|
filename: name.to_owned(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn prefers_merged_file_over_colliding_shards() {
|
|
let siblings = vec![
|
|
sibling("qwen2.5-coder-7b-instruct-q5_k_m-00001-of-00002.gguf"),
|
|
sibling("qwen2.5-coder-7b-instruct-q5_k_m-00002-of-00002.gguf"),
|
|
sibling("qwen2.5-coder-7b-instruct-q5_k_m.gguf"),
|
|
];
|
|
|
|
let files = select_gguf_files(&siblings, Some("q5_k_m"));
|
|
|
|
assert_eq!(files, vec!["qwen2.5-coder-7b-instruct-q5_k_m.gguf"]);
|
|
}
|
|
|
|
#[test]
|
|
fn selects_all_shards_sorted_by_index_when_no_merged_file() {
|
|
let siblings = vec![
|
|
sibling("model-q4_k_m-00002-of-00003.gguf"),
|
|
sibling("model-q4_k_m-00001-of-00003.gguf"),
|
|
sibling("model-q4_k_m-00003-of-00003.gguf"),
|
|
sibling("model-q8_0-00001-of-00002.gguf"),
|
|
];
|
|
|
|
let files = select_gguf_files(&siblings, Some("q4_k_m"));
|
|
|
|
assert_eq!(
|
|
files,
|
|
vec![
|
|
"model-q4_k_m-00001-of-00003.gguf",
|
|
"model-q4_k_m-00002-of-00003.gguf",
|
|
"model-q4_k_m-00003-of-00003.gguf",
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn returns_empty_when_no_match() {
|
|
let siblings = vec![sibling("model-q4_k_m.gguf")];
|
|
|
|
assert!(select_gguf_files(&siblings, Some("q5_k_m")).is_empty());
|
|
}
|
|
}
|