fix(model-server): deadline de warmup configurable pour le cold-start llama.cpp (#55)

Corrige la régression high « Error on loading local model » : le premier chargement
du serveur modèle local (cold-start llama.cpp) dépassait la fenêtre de readiness et
échouait. Le warmup dispose désormais d'un deadline par défaut de 600 s, surchargable
par config optionnelle `warmup_deadline_secs` (validée dans [30, 1800]).

- domain: champ `warmup_deadline_secs: Option<u64>` + validation de borne
- application: policy de readiness effective (défaut 600 s, override par config)
- app-tauri: DTO `warmupDeadlineSecs`
- infrastructure: application de la deadline effective au warmup

Verdict QA (vert) : domain 252, application 81 + 22 model_server, app-tauri
dto_model_servers 6, infrastructure model_server 2, build OK. Contrat readiness
couvert sur ports mockés.
Caveat : le cold-start end-to-end réel (llama-server) sort du sandbox de test ->
vérification manuelle utilisateur restante, non couverte par les tests unitaires.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 08:41:44 +02:00
parent ce5aa2811c
commit f7cae4c0a8
7 changed files with 200 additions and 7 deletions

View File

@ -6,7 +6,7 @@
use std::net::Ipv4Addr;
use serde::{Deserialize, Serialize};
use serde::{Deserialize, Deserializer, Serialize};
use crate::error::DomainError;
use crate::ids::LocalModelServerId;
@ -283,6 +283,12 @@ pub struct LlamaCppOptions {
pub jinja: bool,
}
/// Minimum configurable server warmup deadline in seconds.
pub const MIN_WARMUP_DEADLINE_SECS: u64 = 30;
/// Maximum configurable server warmup deadline in seconds.
pub const MAX_WARMUP_DEADLINE_SECS: u64 = 1_800;
impl Default for LlamaCppOptions {
fn default() -> Self {
Self {
@ -376,6 +382,27 @@ pub fn validate_free_args(args: &[String]) -> Result<(), DomainError> {
Ok(())
}
fn validate_warmup_deadline_secs(value: Option<u64>) -> Result<(), DomainError> {
let Some(value) = value else {
return Ok(());
};
if !(MIN_WARMUP_DEADLINE_SECS..=MAX_WARMUP_DEADLINE_SECS).contains(&value) {
return Err(DomainError::Invariant(format!(
"modelServer.warmupDeadlineSecs must be between {MIN_WARMUP_DEADLINE_SECS} and {MAX_WARMUP_DEADLINE_SECS}"
)));
}
Ok(())
}
fn deserialize_warmup_deadline_secs<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
where
D: Deserializer<'de>,
{
let value = Option::<u64>::deserialize(deserializer)?;
validate_warmup_deadline_secs(value).map_err(serde::de::Error::custom)?;
Ok(value)
}
/// Stop policy for a managed local model server.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@ -415,6 +442,15 @@ pub struct LocalModelServerConfig {
/// Process stop policy.
#[serde(default = "default_stop_policy")]
pub stop_policy: StopPolicy,
/// Optional readiness warmup deadline override in seconds.
///
/// Absent means the application-level default applies.
#[serde(
default,
deserialize_with = "deserialize_warmup_deadline_secs",
skip_serializing_if = "Option::is_none"
)]
pub warmup_deadline_secs: Option<u64>,
}
fn default_stop_policy() -> StopPolicy {
@ -467,8 +503,23 @@ impl LocalModelServerConfig {
args,
auto_start,
stop_policy,
warmup_deadline_secs: None,
})
}
/// Returns a copy with a validated readiness warmup deadline override.
///
/// # Errors
/// Returns [`DomainError`] if the value is outside the supported
/// `[30, 1800]` seconds range.
pub fn with_warmup_deadline_secs(
mut self,
warmup_deadline_secs: Option<u64>,
) -> Result<Self, DomainError> {
validate_warmup_deadline_secs(warmup_deadline_secs)?;
self.warmup_deadline_secs = warmup_deadline_secs;
Ok(self)
}
}
/// Observed model-server status.
@ -579,6 +630,50 @@ mod tests {
assert!(HfModelRef::new("a/b:c:d").is_err());
}
#[test]
fn warmup_deadline_secs_is_optional_and_validated() {
let endpoint = ModelServerEndpoint::new("http://localhost:8080/v1", 8080).unwrap();
let model = LocalModelRef::new(
"qwen",
"Qwen",
Some(ModelSource::LocalPath {
path: ModelPath::new("/models/qwen.gguf").unwrap(),
}),
"qwen",
)
.unwrap();
let config = LocalModelServerConfig::new(
LocalModelServerId::from_uuid(uuid::Uuid::nil()),
LocalModelServerKind::LlamaCpp,
"llama.cpp",
endpoint,
model,
None,
LlamaCppOptions::default(),
Vec::new(),
true,
StopPolicy::StopOnAppExit,
)
.unwrap();
assert_eq!(config.warmup_deadline_secs, None);
assert_eq!(
config
.clone()
.with_warmup_deadline_secs(Some(MIN_WARMUP_DEADLINE_SECS))
.unwrap()
.warmup_deadline_secs,
Some(MIN_WARMUP_DEADLINE_SECS)
);
assert!(config
.clone()
.with_warmup_deadline_secs(Some(MIN_WARMUP_DEADLINE_SECS - 1))
.is_err());
assert!(config
.with_warmup_deadline_secs(Some(MAX_WARMUP_DEADLINE_SECS + 1))
.is_err());
}
#[test]
fn validate_free_args_rejects_reserved_flags_token_and_equals() {
for flag in [