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

@ -1,12 +1,12 @@
//! DTO contract tests for local model-server IPC.
use app_tauri_lib::dto::{
ModelServerConfigDto, ModelServerKindDto, SaveModelServerRequestDto, StopPolicyDto,
save_model_server_input,
save_model_server_input, ModelServerConfigDto, ModelServerKindDto, ModelSourceDto,
SaveModelServerRequestDto, StopPolicyDto,
};
use domain::model_server::{
ExecutablePath, LocalModelRef, LocalModelServerConfig, LocalModelServerKind, ModelPath,
ModelServerEndpoint,
ExecutablePath, LlamaCppOptions, LocalModelRef, LocalModelServerConfig, LocalModelServerKind,
ModelPath, ModelServerEndpoint, ModelSource,
};
use domain::{LocalModelServerId, StopPolicy};
use serde_json::json;
@ -25,12 +25,15 @@ fn domain_config(id: LocalModelServerId, model_id: &str) -> LocalModelServerConf
LocalModelRef::new(
model_id,
"Qwen",
Some(ModelPath::new("/models/qwen.gguf").unwrap()),
Some(ModelSource::LocalPath {
path: ModelPath::new("/models/qwen.gguf").unwrap(),
}),
"qwen3-coder",
)
.unwrap(),
Some(ExecutablePath::new("/usr/bin/llama-server").unwrap()),
vec!["--ctx-size".to_owned(), "4096".to_owned()],
LlamaCppOptions::new("127.0.0.1", Some(35), Some(4096), true).unwrap(),
vec!["--threads".to_owned(), "8".to_owned()],
true,
StopPolicy::StopOnAppExit,
)
@ -46,12 +49,21 @@ fn model_server_dto_serialises_flat_camelcase_wire_shape() {
assert_eq!(value["id"], sid(35).to_string());
assert_eq!(value["kind"], "llamaCpp");
assert_eq!(value["baseURL"], "http://localhost:8080/v1");
assert_eq!(value["modelPath"], "/models/qwen.gguf");
assert_eq!(value["modelSource"]["type"], "localPath");
assert_eq!(value["modelSource"]["path"], "/models/qwen.gguf");
assert!(value.get("modelPath").is_none());
assert_eq!(value["servedModelName"], "qwen3-coder");
assert_eq!(value["binaryPath"], "/usr/bin/llama-server");
assert_eq!(value["host"], "127.0.0.1");
assert_eq!(value["gpuLayers"], 35);
assert_eq!(value["contextSize"], 4096);
assert_eq!(value["jinja"], true);
assert_eq!(value["stopPolicy"], "stopOnAppExit");
assert!(value.get("model").is_none(), "domain model must not leak");
assert!(value.get("endpoint").is_none(), "domain endpoint must not leak");
assert!(
value.get("endpoint").is_none(),
"domain endpoint must not leak"
);
}
#[test]
@ -65,7 +77,9 @@ fn model_server_dto_deserialises_flat_wire_shape_and_generates_model_id() {
"modelPath": "/models/qwen.gguf",
"servedModelName": "qwen3-coder",
"binaryPath": "/usr/bin/llama-server",
"args": ["--ctx-size", "4096"],
"gpuLayers": 35,
"contextSize": 4096,
"args": ["--threads", "8"],
"autoStart": true,
"stopPolicy": "stopOnAppExit"
});
@ -79,9 +93,15 @@ fn model_server_dto_deserialises_flat_wire_shape_and_generates_model_id() {
assert_eq!(input.config.kind, LocalModelServerKind::LlamaCpp);
assert_eq!(input.config.endpoint.base_url, "http://localhost:8081/v1");
assert_eq!(
input.config.model.path.unwrap().as_str(),
"/models/qwen.gguf"
input.config.model.source,
Some(ModelSource::LocalPath {
path: ModelPath::new("/models/qwen.gguf").unwrap()
})
);
assert_eq!(input.config.options.gpu_layers, Some(35));
assert_eq!(input.config.options.context_size, Some(4096));
assert_eq!(input.config.options.host, "127.0.0.1");
assert!(!input.config.options.jinja);
assert_eq!(input.config.model.served_name, "qwen3-coder");
assert_eq!(input.config.model.label, "qwen3-coder");
assert!(!input.config.model.id.is_empty());
@ -96,9 +116,16 @@ fn model_server_dto_preserves_existing_internal_model_id_on_upsert() {
name: "Updated Qwen".to_owned(),
base_url: "http://localhost:8082/v1".to_owned(),
port: 8082,
model_path: Some("/models/qwen-updated.gguf".to_owned()),
model_source: Some(ModelSourceDto::LocalPath {
path: "/models/qwen-updated.gguf".to_owned(),
}),
model_path: None,
served_model_name: "qwen3-coder-updated".to_owned(),
binary_path: Some("/usr/bin/llama-server".to_owned()),
host: "127.0.0.1".to_owned(),
gpu_layers: None,
context_size: None,
jinja: false,
args: Vec::new(),
auto_start: true,
stop_policy: StopPolicyDto::StopOnAppExit,
@ -109,3 +136,66 @@ fn model_server_dto_preserves_existing_internal_model_id_on_upsert() {
assert_eq!(config.model.id, "stable-internal-id");
assert_eq!(config.model.served_name, "qwen3-coder-updated");
}
#[test]
fn model_server_dto_roundtrips_v2_huggingface_source() {
let raw = json!({
"id": sid(38).to_string(),
"kind": "llamaCpp",
"name": "HF Qwen",
"baseURL": "http://localhost:8083",
"port": 8083,
"modelSource": { "type": "huggingFace", "repo": "Qwen/Qwen3-Coder:Q4_K_M" },
"servedModelName": "qwen3-coder",
"binaryPath": "/usr/bin/llama-server",
"host": "127.0.0.1",
"jinja": false,
"args": [],
"autoStart": true,
"stopPolicy": "stopOnAppExit"
});
let request = SaveModelServerRequestDto {
config: serde_json::from_value(raw).unwrap(),
};
let input = save_model_server_input(request, None).unwrap();
assert_eq!(
input.config.model.source,
Some(ModelSource::HuggingFace {
repo: domain::model_server::HfModelRef::new("Qwen/Qwen3-Coder:Q4_K_M").unwrap()
})
);
let value = serde_json::to_value(ModelServerConfigDto::from_domain(input.config)).unwrap();
assert_eq!(value["modelSource"]["type"], "huggingFace");
assert_eq!(value["modelSource"]["repo"], "Qwen/Qwen3-Coder:Q4_K_M");
assert!(value.get("modelPath").is_none());
}
#[test]
fn model_server_dto_rejects_conflicting_model_path_alias() {
let raw = json!({
"id": sid(39).to_string(),
"kind": "llamaCpp",
"name": "Local Qwen",
"baseURL": "http://localhost:8084",
"port": 8084,
"modelSource": { "type": "localPath", "path": "/models/a.gguf" },
"modelPath": "/models/b.gguf",
"servedModelName": "qwen3-coder",
"host": "127.0.0.1",
"jinja": false,
"args": [],
"autoStart": true,
"stopPolicy": "stopOnAppExit"
});
let request = SaveModelServerRequestDto {
config: serde_json::from_value(raw).unwrap(),
};
assert_eq!(
save_model_server_input(request, None).unwrap_err().code,
"INVALID"
);
}