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:
@ -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 {
|
||||
|
||||
@ -4,8 +4,8 @@ use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::model_server::{
|
||||
ExecutablePath, LocalModelRef, LocalModelServerConfig, LocalModelServerKind, ModelPath,
|
||||
ModelServerEndpoint, StopPolicy,
|
||||
ExecutablePath, HfModelRef, LlamaCppOptions, LocalModelRef, LocalModelServerConfig,
|
||||
LocalModelServerKind, ModelPath, ModelServerEndpoint, ModelSource, StopPolicy,
|
||||
};
|
||||
use domain::ports::{FileSystem, ModelServerRegistry, ModelServerRuntime, RemotePath};
|
||||
use domain::LocalModelServerId;
|
||||
@ -53,12 +53,15 @@ fn config(id: LocalModelServerId, port: u16) -> LocalModelServerConfig {
|
||||
LocalModelRef::new(
|
||||
"qwen",
|
||||
"Qwen",
|
||||
Some(ModelPath::new("/models/qwen.gguf").unwrap()),
|
||||
Some(ModelSource::LocalPath {
|
||||
path: ModelPath::new("/models/qwen.gguf").unwrap(),
|
||||
}),
|
||||
"qwen3-coder-30b",
|
||||
)
|
||||
.unwrap(),
|
||||
Some(ExecutablePath::new(binary).unwrap()),
|
||||
vec!["--ctx-size".to_owned(), "8192".to_owned()],
|
||||
LlamaCppOptions::new("127.0.0.1", Some(35), Some(8192), true).unwrap(),
|
||||
vec!["--threads".to_owned(), "8".to_owned()],
|
||||
true,
|
||||
StopPolicy::StopOnAppExit,
|
||||
)
|
||||
@ -67,23 +70,54 @@ fn config(id: LocalModelServerId, port: u16) -> LocalModelServerConfig {
|
||||
|
||||
#[test]
|
||||
fn llamacpp_runtime_builds_structured_argv() {
|
||||
let spec = LlamaCppRuntime::new()
|
||||
.build_spawn_spec(&config(sid(1), 8080))
|
||||
let argv = LlamaCppRuntime::new()
|
||||
.build_argv(&config(sid(1), 8080))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
spec.command,
|
||||
argv.command,
|
||||
std::env::current_exe().unwrap().to_string_lossy()
|
||||
);
|
||||
assert_eq!(
|
||||
spec.args,
|
||||
argv.args,
|
||||
vec![
|
||||
"--model",
|
||||
"/models/qwen.gguf",
|
||||
"--port",
|
||||
"8080",
|
||||
"--ctx-size",
|
||||
"8192"
|
||||
"--host",
|
||||
"127.0.0.1",
|
||||
"-ngl",
|
||||
"35",
|
||||
"-c",
|
||||
"8192",
|
||||
"--jinja",
|
||||
"--threads",
|
||||
"8"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn llamacpp_runtime_builds_hf_argv() {
|
||||
let mut config = config(sid(3), 8083);
|
||||
config.model.source = Some(ModelSource::HuggingFace {
|
||||
repo: HfModelRef::new("Qwen/Qwen3-Coder:Q4_K_M").unwrap(),
|
||||
});
|
||||
config.options = LlamaCppOptions::default();
|
||||
config.args = Vec::new();
|
||||
|
||||
let spec = LlamaCppRuntime::new().build_spawn_spec(&config).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
spec.args,
|
||||
vec![
|
||||
"-hf",
|
||||
"Qwen/Qwen3-Coder:Q4_K_M",
|
||||
"--port",
|
||||
"8083",
|
||||
"--host",
|
||||
"127.0.0.1"
|
||||
]
|
||||
);
|
||||
assert!(spec.context_plan.is_none());
|
||||
@ -109,3 +143,49 @@ async fn fs_model_server_registry_roundtrips_global_json() {
|
||||
registry.save(updated.clone()).await.unwrap();
|
||||
assert_eq!(registry.list().await.unwrap(), vec![updated]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fs_model_server_registry_migrates_v1_json_to_v2() {
|
||||
let tmp = TempDir::new();
|
||||
let fs: Arc<dyn FileSystem> = Arc::new(LocalFileSystem::new());
|
||||
fs.create_dir_all(&RemotePath::new(tmp.app_data_dir()))
|
||||
.await
|
||||
.unwrap();
|
||||
fs.write(
|
||||
&tmp.child("model-servers.json"),
|
||||
br#"{
|
||||
"version": 1,
|
||||
"servers": [{
|
||||
"id": "00000000-0000-0000-0000-000000000004",
|
||||
"kind": "llamaCpp",
|
||||
"name": "Legacy Qwen",
|
||||
"endpoint": { "baseURL": "http://localhost:8084/v1", "port": 8084 },
|
||||
"model": {
|
||||
"id": "qwen",
|
||||
"label": "Qwen",
|
||||
"path": "/models/qwen.gguf",
|
||||
"servedName": "qwen3-coder"
|
||||
},
|
||||
"binary": "/usr/bin/llama-server",
|
||||
"args": ["--ctx-size", "4096"],
|
||||
"autoStart": true,
|
||||
"stopPolicy": "stopOnAppExit"
|
||||
}]
|
||||
}"#,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let registry = FsModelServerRegistry::new(Arc::clone(&fs), tmp.app_data_dir());
|
||||
|
||||
let servers = registry.list().await.unwrap();
|
||||
|
||||
assert_eq!(servers.len(), 1);
|
||||
assert_eq!(servers[0].options, LlamaCppOptions::default());
|
||||
assert_eq!(servers[0].args, vec!["--ctx-size", "4096"]);
|
||||
assert_eq!(
|
||||
servers[0].model.source,
|
||||
Some(ModelSource::LocalPath {
|
||||
path: ModelPath::new("/models/qwen.gguf").unwrap()
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user