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:
@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user