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>
230 lines
6.5 KiB
Rust
230 lines
6.5 KiB
Rust
//! Infrastructure tests for local model-server adapters.
|
|
|
|
use std::path::PathBuf;
|
|
use std::sync::Arc;
|
|
|
|
use domain::model_server::{
|
|
ExecutablePath, HfModelRef, LlamaCppOptions, LocalModelRef, LocalModelServerConfig,
|
|
LocalModelServerKind, ModelPath, ModelServerEndpoint, ModelSource, StopPolicy,
|
|
};
|
|
use domain::ports::{
|
|
FileSystem, ModelArtifactCancel, ModelArtifactDownloader, ModelServerRegistry,
|
|
ModelServerRuntime, RemotePath,
|
|
};
|
|
use domain::LocalModelServerId;
|
|
use infrastructure::{
|
|
FsModelServerRegistry, HfModelArtifactDownloader, LlamaCppRuntime, LocalFileSystem,
|
|
};
|
|
use uuid::Uuid;
|
|
|
|
struct TempDir(PathBuf);
|
|
|
|
impl TempDir {
|
|
fn new() -> Self {
|
|
let path = std::env::temp_dir().join(format!("idea-model-server-{}", Uuid::new_v4()));
|
|
std::fs::create_dir_all(&path).unwrap();
|
|
Self(path)
|
|
}
|
|
|
|
fn app_data_dir(&self) -> String {
|
|
self.0.to_string_lossy().into_owned()
|
|
}
|
|
|
|
fn child(&self, name: &str) -> RemotePath {
|
|
RemotePath::new(self.0.join(name).to_string_lossy().into_owned())
|
|
}
|
|
|
|
fn path(&self) -> &std::path::Path {
|
|
&self.0
|
|
}
|
|
}
|
|
|
|
impl Drop for TempDir {
|
|
fn drop(&mut self) {
|
|
let _ = std::fs::remove_dir_all(&self.0);
|
|
}
|
|
}
|
|
|
|
fn sid(n: u128) -> LocalModelServerId {
|
|
LocalModelServerId::from_uuid(Uuid::from_u128(n))
|
|
}
|
|
|
|
fn config(id: LocalModelServerId, port: u16) -> LocalModelServerConfig {
|
|
let binary = std::env::current_exe()
|
|
.unwrap()
|
|
.to_string_lossy()
|
|
.into_owned();
|
|
LocalModelServerConfig::new(
|
|
id,
|
|
LocalModelServerKind::LlamaCpp,
|
|
"Local Qwen",
|
|
ModelServerEndpoint::new(format!("http://localhost:{port}/v1"), port).unwrap(),
|
|
LocalModelRef::new(
|
|
"qwen",
|
|
"Qwen",
|
|
Some(ModelSource::LocalPath {
|
|
path: ModelPath::new("/models/qwen.gguf").unwrap(),
|
|
}),
|
|
"qwen3-coder-30b",
|
|
)
|
|
.unwrap(),
|
|
Some(ExecutablePath::new(binary).unwrap()),
|
|
LlamaCppOptions::new("127.0.0.1", Some(35), Some(8192), true).unwrap(),
|
|
vec!["--threads".to_owned(), "8".to_owned()],
|
|
true,
|
|
StopPolicy::StopOnAppExit,
|
|
)
|
|
.unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn llamacpp_runtime_builds_structured_argv() {
|
|
let argv = LlamaCppRuntime::new()
|
|
.build_argv(&config(sid(1), 8080))
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
argv.command,
|
|
std::env::current_exe().unwrap().to_string_lossy()
|
|
);
|
|
assert_eq!(
|
|
argv.args,
|
|
vec![
|
|
"--model",
|
|
"/models/qwen.gguf",
|
|
"--port",
|
|
"8080",
|
|
"--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());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn fs_model_server_registry_roundtrips_global_json() {
|
|
let tmp = TempDir::new();
|
|
let fs: Arc<dyn FileSystem> = Arc::new(LocalFileSystem::new());
|
|
let registry = FsModelServerRegistry::new(Arc::clone(&fs), tmp.app_data_dir());
|
|
|
|
let first = config(sid(2), 8081)
|
|
.with_warmup_deadline_secs(Some(900))
|
|
.unwrap();
|
|
registry.save(first.clone()).await.unwrap();
|
|
|
|
assert_eq!(registry.get(&first.id).await.unwrap(), Some(first.clone()));
|
|
assert_eq!(registry.list().await.unwrap(), vec![first.clone()]);
|
|
assert!(
|
|
fs.exists(&tmp.child("model-servers.json")).await.unwrap(),
|
|
"registry must live in global app-data dir"
|
|
);
|
|
|
|
let updated = config(sid(2), 8082);
|
|
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].warmup_deadline_secs, None);
|
|
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()
|
|
})
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hf_model_artifact_downloader_resolves_deterministic_local_cache_hit_without_network() {
|
|
let tmp = TempDir::new();
|
|
let downloader = HfModelArtifactDownloader::new(tmp.path());
|
|
let repo = HfModelRef::new("Qwen/Qwen3-Coder:Q4_K_M").unwrap();
|
|
let path = downloader.cache_path_for(&repo);
|
|
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
|
std::fs::write(&path, b"gguf").unwrap();
|
|
|
|
let resolution = downloader
|
|
.resolve_hf_model(
|
|
&repo,
|
|
Arc::new(|_| panic!("cache hit must not report progress")),
|
|
ModelArtifactCancel::new(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(resolution.cache_hit);
|
|
assert_eq!(resolution.path.as_str(), path.to_string_lossy());
|
|
assert_eq!(
|
|
path.strip_prefix(tmp.path()).unwrap(),
|
|
std::path::Path::new("Qwen--Qwen3-Coder").join("Q4_K_M.gguf")
|
|
);
|
|
}
|