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

@ -7,6 +7,7 @@ use std::time::Duration;
use domain::events::DomainEvent;
use domain::model_server::{
LocalModelServerConfig, ModelServerLifecycleStatus, ModelServerReady, ModelServerStatus,
ModelSource,
};
use domain::ports::{
EventBus, FileSystem, ManagedProcess, ManagedProcessHandle, ModelServerError, ModelServerProbe,
@ -332,11 +333,14 @@ impl EnsureLocalModelServer {
&self,
config: &LocalModelServerConfig,
) -> Result<(), AppError> {
let Some(path) = config.model.path.as_ref() else {
let err = ModelServerError::PathNotAccessible("model.path missing".to_owned());
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()))

View File

@ -12,13 +12,13 @@ use application::{
};
use domain::events::DomainEvent;
use domain::model_server::{
ExecutablePath, LocalModelRef, LocalModelServerConfig, LocalModelServerKind, ModelPath,
ModelServerEndpoint, ModelServerStatus, StopPolicy,
ExecutablePath, LlamaCppOptions, LocalModelRef, LocalModelServerConfig, LocalModelServerKind,
ModelPath, ModelServerEndpoint, ModelServerStatus, ModelSource, StopPolicy,
};
use domain::ports::{
DirEntry, EventBus, EventStream, FileSystem, FsError, ManagedProcess, ManagedProcessHandle,
ModelServerError, ModelServerProbe, ModelServerRegistry, ModelServerRuntime, ProcessStatus,
ProfileStore, RemotePath, SpawnSpec, StoreError,
ModelServerArgv, ModelServerError, ModelServerProbe, ModelServerRegistry, ModelServerRuntime,
ProcessStatus, ProfileStore, RemotePath, SpawnSpec, StoreError,
};
use domain::profile::{AgentProfile, ContextInjection, OpenCodeConfig, StructuredAdapter};
use domain::{LocalModelServerId, ProfileId, ProjectPath};
@ -41,11 +41,14 @@ fn config(
LocalModelRef::new(
"qwen",
"Qwen",
Some(ModelPath::new(path).unwrap()),
Some(ModelSource::LocalPath {
path: ModelPath::new(path).unwrap(),
}),
"qwen3-coder-30b",
)
.unwrap(),
Some(ExecutablePath::new("llama-server").unwrap()),
LlamaCppOptions::default(),
Vec::new(),
auto_start,
StopPolicy::StopOnAppExit,
@ -192,11 +195,16 @@ impl ManagedProcess for FakeProcess {
struct FakeRuntime;
impl ModelServerRuntime for FakeRuntime {
fn build_spawn_spec(
fn build_argv(
&self,
config: &LocalModelServerConfig,
) -> Result<SpawnSpec, ModelServerError> {
Ok(SpawnSpec {
) -> Result<ModelServerArgv, ModelServerError> {
let Some(ModelSource::LocalPath { path }) = config.model.source.as_ref() else {
return Err(ModelServerError::PathNotAccessible(
"model.source missing".to_owned(),
));
};
Ok(ModelServerArgv {
command: config
.binary
.as_ref()
@ -204,10 +212,23 @@ impl ModelServerRuntime for FakeRuntime {
.unwrap_or_else(|| "llama-server".to_owned()),
args: vec![
"--model".to_owned(),
config.model.path.as_ref().unwrap().as_str().to_owned(),
path.as_str().to_owned(),
"--port".to_owned(),
config.endpoint.port.to_string(),
"--host".to_owned(),
config.options.host.clone(),
],
})
}
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("/").unwrap(),
env: Vec::new(),
context_plan: None,
@ -341,7 +362,14 @@ async fn absent_auto_start_spawns_and_waits_until_ready() {
assert_eq!(spawns[0].command, "llama-server");
assert_eq!(
spawns[0].args,
vec!["--model", "/models/qwen.gguf", "--port", "8081"]
vec![
"--model",
"/models/qwen.gguf",
"--port",
"8081",
"--host",
"127.0.0.1"
]
);
}