feat(model-server): source Hugging Face (-hf) + options structurées llama.cpp + migration store V2

Backend du support natif d'une source modèle Hugging Face en alternative au
chemin .gguf local pour le serveur llama.cpp intégré, et refonte des options.

- domaine : ModelSource {LocalPath|HuggingFace} + HfModelRef,
  LlamaCppOptions {host,gpu_layers,context_size,jinja}, invariant auto_start
  exigeant une source, validate_free_args (rejet des flags réservés).
- infra : build_argv partagé avec build_spawn_spec, émission -hf vs --model,
  ordre argv figé ; migration model-servers.json V1 -> V2.
- app-tauri : DTO V2 (modelSource, compat modelPath, conflit = INVALID),
  commande preview_model_server_command.

QA : cargo test ciblés verts (domain 7, application 8, infra model_server 4,
app-tauri dto 5). Échecs des suites complètes infra/app-tauri environnementaux
(sandbox bind/socket), hors périmètre.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-12 14:31:54 +02:00
parent bc96285fa5
commit 38aecf2cac
11 changed files with 820 additions and 102 deletions

View File

@ -9,12 +9,16 @@ use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use tokio::process::{Child, Command};
use domain::model_server::{LocalModelServerConfig, LocalModelServerKind, ModelServerEndpoint};
use domain::ports::{
FileSystem, ManagedProcess, ManagedProcessHandle, ModelServerError, ModelServerProbe,
ModelServerRegistry, ModelServerRuntime, ProcessStatus, RemotePath, SpawnSpec,
use domain::model_server::{
ExecutablePath, LlamaCppOptions, LocalModelRef, LocalModelServerConfig, LocalModelServerKind,
ModelPath, ModelServerEndpoint, ModelSource,
};
use domain::{LocalModelServerId, ProjectPath};
use domain::ports::{
FileSystem, ManagedProcess, ManagedProcessHandle, ModelServerArgv, ModelServerError,
ModelServerProbe, ModelServerRegistry, ModelServerRuntime, ProcessStatus, RemotePath,
SpawnSpec,
};
use domain::{LocalModelServerId, ProjectPath, StopPolicy};
/// HTTP readiness probe for OpenAI-compatible servers.
#[derive(Clone)]
@ -77,19 +81,15 @@ impl LlamaCppRuntime {
}
impl ModelServerRuntime for LlamaCppRuntime {
fn build_spawn_spec(
fn build_argv(
&self,
config: &LocalModelServerConfig,
) -> Result<SpawnSpec, ModelServerError> {
) -> Result<ModelServerArgv, ModelServerError> {
if config.kind != LocalModelServerKind::LlamaCpp {
return Err(ModelServerError::Invalid(
"only LlamaCpp local model servers are supported".to_owned(),
));
}
let model_path =
config.model.path.as_ref().ok_or_else(|| {
ModelServerError::PathNotAccessible("model.path missing".to_owned())
})?;
let command = resolve_binary(
config
.binary
@ -97,16 +97,49 @@ impl ModelServerRuntime for LlamaCppRuntime {
.map(|binary| binary.as_str())
.unwrap_or("llama-server"),
)?;
let mut args = vec![
"--model".to_owned(),
model_path.as_str().to_owned(),
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,
args,
command: argv.command,
args: argv.args,
cwd: ProjectPath::new("/").map_err(|e| ModelServerError::Invalid(e.to_string()))?,
env: Vec::new(),
context_plan: None,
@ -207,7 +240,7 @@ impl ManagedProcess for LocalManagedProcess {
/// File name of the global local-model-server registry.
const MODEL_SERVERS_FILE: &str = "model-servers.json";
const MODEL_SERVERS_VERSION: u32 = 1;
const MODEL_SERVERS_VERSION: u32 = 2;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@ -216,6 +249,82 @@ struct ModelServersDoc {
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,
})
.collect(),
}
}
}
impl Default for ModelServersDoc {
fn default() -> Self {
Self {
@ -249,8 +358,15 @@ impl FsModelServerRegistry {
async fn read_doc(&self) -> Result<ModelServersDoc, ModelServerError> {
match self.fs.read(&self.path()).await {
Ok(bytes) => serde_json::from_slice(&bytes)
.map_err(|e| ModelServerError::Store(format!("serialization failed: {e}"))),
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))
@ -298,7 +414,14 @@ impl ModelServerRegistry for FsModelServerRegistry {
}
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 {