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:
@ -116,6 +116,81 @@ impl ModelPath {
|
||||
}
|
||||
}
|
||||
|
||||
/// Explicit source of the served model.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", tag = "type")]
|
||||
pub enum ModelSource {
|
||||
/// Local `.gguf` file path.
|
||||
LocalPath {
|
||||
/// Absolute local path.
|
||||
path: ModelPath,
|
||||
},
|
||||
/// Hugging Face llama.cpp `-hf` reference.
|
||||
HuggingFace {
|
||||
/// `namespace/repo` or `namespace/repo:quant`.
|
||||
repo: HfModelRef,
|
||||
},
|
||||
}
|
||||
|
||||
/// Hugging Face model reference accepted by `llama-server -hf`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct HfModelRef(pub String);
|
||||
|
||||
impl HfModelRef {
|
||||
/// Builds a validated Hugging Face model reference.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`DomainError`] if the reference is blank, URL-like, contains
|
||||
/// spaces, or does not match `namespace/repo[:quant]`.
|
||||
pub fn new(repo: impl Into<String>) -> Result<Self, DomainError> {
|
||||
let repo = repo.into();
|
||||
crate::validation::non_empty(&repo, "modelServer.model.source.repo")?;
|
||||
if repo.chars().any(char::is_whitespace) {
|
||||
return Err(DomainError::Invariant(
|
||||
"modelServer.model.source.repo must not contain spaces".to_owned(),
|
||||
));
|
||||
}
|
||||
if repo.starts_with("http://") || repo.starts_with("https://") {
|
||||
return Err(DomainError::Invariant(
|
||||
"modelServer.model.source.repo must not be an URL in V1".to_owned(),
|
||||
));
|
||||
}
|
||||
if repo.matches(':').count() > 1 {
|
||||
return Err(DomainError::Invariant(
|
||||
"modelServer.model.source.repo must contain at most one ':'".to_owned(),
|
||||
));
|
||||
}
|
||||
let (base, quant) = repo
|
||||
.split_once(':')
|
||||
.map_or((repo.as_str(), None), |(base, quant)| (base, Some(quant)));
|
||||
let Some((namespace, name)) = base.split_once('/') else {
|
||||
return Err(DomainError::Invariant(
|
||||
"modelServer.model.source.repo must be namespace/repo or namespace/repo:quant"
|
||||
.to_owned(),
|
||||
));
|
||||
};
|
||||
if namespace.is_empty() || name.is_empty() || base.matches('/').count() != 1 {
|
||||
return Err(DomainError::Invariant(
|
||||
"modelServer.model.source.repo must be namespace/repo or namespace/repo:quant"
|
||||
.to_owned(),
|
||||
));
|
||||
}
|
||||
if quant.is_some_and(str::is_empty) {
|
||||
return Err(DomainError::Invariant(
|
||||
"modelServer.model.source.repo quant must not be empty".to_owned(),
|
||||
));
|
||||
}
|
||||
Ok(Self(repo))
|
||||
}
|
||||
|
||||
/// Returns the reference as a string slice.
|
||||
#[must_use]
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Absolute local path to an executable.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
@ -159,9 +234,9 @@ pub struct LocalModelRef {
|
||||
pub id: String,
|
||||
/// Human display label.
|
||||
pub label: String,
|
||||
/// Explicit absolute `.gguf` path. Required for auto-start.
|
||||
/// Explicit source. Required for auto-start.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<ModelPath>,
|
||||
pub source: Option<ModelSource>,
|
||||
/// Name exposed by the server and used in `OpenCodeConfig.model`.
|
||||
pub served_name: String,
|
||||
}
|
||||
@ -174,7 +249,7 @@ impl LocalModelRef {
|
||||
pub fn new(
|
||||
id: impl Into<String>,
|
||||
label: impl Into<String>,
|
||||
path: Option<ModelPath>,
|
||||
source: Option<ModelSource>,
|
||||
served_name: impl Into<String>,
|
||||
) -> Result<Self, DomainError> {
|
||||
let id = id.into();
|
||||
@ -186,12 +261,121 @@ impl LocalModelRef {
|
||||
Ok(Self {
|
||||
id,
|
||||
label,
|
||||
path,
|
||||
source,
|
||||
served_name,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Structured llama.cpp server options owned by IdeA.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LlamaCppOptions {
|
||||
/// Host passed to `--host`.
|
||||
pub host: String,
|
||||
/// GPU layers passed to `-ngl`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub gpu_layers: Option<u32>,
|
||||
/// Context size passed to `-c`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub context_size: Option<u32>,
|
||||
/// Enables llama.cpp Jinja templates.
|
||||
pub jinja: bool,
|
||||
}
|
||||
|
||||
impl Default for LlamaCppOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
host: "127.0.0.1".to_owned(),
|
||||
gpu_layers: None,
|
||||
context_size: None,
|
||||
jinja: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl LlamaCppOptions {
|
||||
/// Builds validated llama.cpp options.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`DomainError`] if host is blank/contains spaces or numeric
|
||||
/// options are zero.
|
||||
pub fn new(
|
||||
host: impl Into<String>,
|
||||
gpu_layers: Option<u32>,
|
||||
context_size: Option<u32>,
|
||||
jinja: bool,
|
||||
) -> Result<Self, DomainError> {
|
||||
let options = Self {
|
||||
host: host.into(),
|
||||
gpu_layers,
|
||||
context_size,
|
||||
jinja,
|
||||
};
|
||||
options.validate()?;
|
||||
Ok(options)
|
||||
}
|
||||
|
||||
/// Validates option invariants.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`DomainError`] on invalid options.
|
||||
pub fn validate(&self) -> Result<(), DomainError> {
|
||||
crate::validation::non_empty(&self.host, "modelServer.options.host")?;
|
||||
if self.host.chars().any(char::is_whitespace) {
|
||||
return Err(DomainError::Invariant(
|
||||
"modelServer.options.host must not contain spaces".to_owned(),
|
||||
));
|
||||
}
|
||||
if self.gpu_layers == Some(0) {
|
||||
return Err(DomainError::Invariant(
|
||||
"modelServer.options.gpuLayers must be > 0".to_owned(),
|
||||
));
|
||||
}
|
||||
if self.context_size == Some(0) {
|
||||
return Err(DomainError::Invariant(
|
||||
"modelServer.options.contextSize must be > 0".to_owned(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates free-form runtime arguments do not duplicate structured options.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`DomainError`] if an argument is a reserved exact flag or
|
||||
/// `--flag=value` form.
|
||||
pub fn validate_free_args(args: &[String]) -> Result<(), DomainError> {
|
||||
const RESERVED: &[&str] = &[
|
||||
"--model",
|
||||
"-m",
|
||||
"-hf",
|
||||
"--port",
|
||||
"--host",
|
||||
"-ngl",
|
||||
"--gpu-layers",
|
||||
"-c",
|
||||
"--ctx-size",
|
||||
"--jinja",
|
||||
];
|
||||
for arg in args {
|
||||
if RESERVED.contains(&arg.as_str()) {
|
||||
return Err(DomainError::Invariant(format!(
|
||||
"modelServer.args contains reserved flag {arg}"
|
||||
)));
|
||||
}
|
||||
if let Some((flag, _)) = arg.split_once('=') {
|
||||
if RESERVED.contains(&flag) {
|
||||
return Err(DomainError::Invariant(format!(
|
||||
"modelServer.args contains reserved flag {flag}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stop policy for a managed local model server.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@ -221,6 +405,8 @@ pub struct LocalModelServerConfig {
|
||||
/// Optional executable path or bare command name.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub binary: Option<ExecutablePath>,
|
||||
/// Structured llama.cpp options.
|
||||
pub options: LlamaCppOptions,
|
||||
/// Extra argv passed to the runtime.
|
||||
#[serde(default)]
|
||||
pub args: Vec<String>,
|
||||
@ -249,21 +435,24 @@ impl LocalModelServerConfig {
|
||||
endpoint: ModelServerEndpoint,
|
||||
model: LocalModelRef,
|
||||
binary: Option<ExecutablePath>,
|
||||
options: LlamaCppOptions,
|
||||
args: Vec<String>,
|
||||
auto_start: bool,
|
||||
stop_policy: StopPolicy,
|
||||
) -> Result<Self, DomainError> {
|
||||
let name = name.into();
|
||||
crate::validation::non_empty(&name, "modelServer.name")?;
|
||||
options.validate()?;
|
||||
validate_free_args(&args)?;
|
||||
if auto_start {
|
||||
if kind != LocalModelServerKind::LlamaCpp {
|
||||
return Err(DomainError::Invariant(
|
||||
"modelServer.autoStart requires kind=LlamaCpp".to_owned(),
|
||||
));
|
||||
}
|
||||
if model.path.is_none() {
|
||||
if model.source.is_none() {
|
||||
return Err(DomainError::Invariant(
|
||||
"modelServer.autoStart requires model.path".to_owned(),
|
||||
"modelServer.autoStart requires model.source".to_owned(),
|
||||
));
|
||||
}
|
||||
}
|
||||
@ -274,6 +463,7 @@ impl LocalModelServerConfig {
|
||||
endpoint,
|
||||
model,
|
||||
binary,
|
||||
options,
|
||||
args,
|
||||
auto_start,
|
||||
stop_policy,
|
||||
@ -342,13 +532,12 @@ mod tests {
|
||||
fn endpoint_rejects_remote_host() {
|
||||
let err = ModelServerEndpoint::new("http://example.com:8080", 8080).unwrap_err();
|
||||
assert!(err.to_string().contains("localhost"));
|
||||
let err =
|
||||
ModelServerEndpoint::new("http://127.0.0.1.evil.com:8080", 8080).unwrap_err();
|
||||
let err = ModelServerEndpoint::new("http://127.0.0.1.evil.com:8080", 8080).unwrap_err();
|
||||
assert!(err.to_string().contains("localhost"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_start_requires_model_path() {
|
||||
fn auto_start_requires_model_source() {
|
||||
let endpoint = ModelServerEndpoint::new("http://localhost:8080/v1", 8080).unwrap();
|
||||
let model = LocalModelRef::new("qwen", "Qwen", None, "qwen").unwrap();
|
||||
let err = LocalModelServerConfig::new(
|
||||
@ -358,11 +547,47 @@ mod tests {
|
||||
endpoint,
|
||||
model,
|
||||
None,
|
||||
LlamaCppOptions::default(),
|
||||
Vec::new(),
|
||||
true,
|
||||
StopPolicy::StopOnAppExit,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("model.path"));
|
||||
assert!(err.to_string().contains("model.source"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hf_model_ref_accepts_repo_and_optional_quant() {
|
||||
assert_eq!(
|
||||
HfModelRef::new("Qwen/Qwen3-Coder:Q4_K_M").unwrap().as_str(),
|
||||
"Qwen/Qwen3-Coder:Q4_K_M"
|
||||
);
|
||||
assert!(HfModelRef::new("Qwen/Qwen3 Coder").is_err());
|
||||
assert!(HfModelRef::new("https://huggingface.co/Qwen/Qwen3").is_err());
|
||||
assert!(HfModelRef::new("Qwen").is_err());
|
||||
assert!(HfModelRef::new("a/b:c:d").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_free_args_rejects_reserved_flags_token_and_equals() {
|
||||
for flag in [
|
||||
"--model",
|
||||
"-m",
|
||||
"-hf",
|
||||
"--port",
|
||||
"--host",
|
||||
"-ngl",
|
||||
"--gpu-layers",
|
||||
"-c",
|
||||
"--ctx-size",
|
||||
"--jinja",
|
||||
] {
|
||||
assert!(validate_free_args(&[flag.to_owned()]).is_err(), "{flag}");
|
||||
assert!(
|
||||
validate_free_args(&[format!("{flag}=value")]).is_err(),
|
||||
"{flag}=value"
|
||||
);
|
||||
}
|
||||
validate_free_args(&["--threads".to_owned(), "8".to_owned()]).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user