feat(model-server): backend modèles locaux — serveur llama.cpp intégré (#35) et profils OpenCode locaux multiples (#36)
Sprint « Modeles locaux », couche backend (domain/application/infra/app-tauri). #35 — Serveur de modèle local intégré (llama.cpp) : - domain: model_server.rs (agrégat + statut), ports ModelServerProbe / ManagedProcess / ModelServerRuntime / ModelServerRegistry, événements model_server_status_changed et agent_launch_failed. - application: use case EnsureLocalModelServer branché sur LaunchAgent. - infrastructure: adapters HttpOpenAiCompatibleProbe, LlamaCppRuntime, LocalManagedProcess, FsModelServerRegistry. - app-tauri: DTO plat LocalModelServerConfigDto, commandes list/save/delete_model_server avec garde model_server_in_use, wiring. #36 — Profils OpenCode locaux multiples : - domain: VO LocalModelServerId, OpenCodeConfig.local_model_server_id. - application: use case CloneOpenCodeProfileFromSeed. - app-tauri: commande clone_opencode_profile_from_seed, DTO/wiring. Tests verts (exécution réelle) : domain+application OK, app-tauri dto_model_servers 3/3 et dto_profiles 10/10, infra model_server 2/2, application model_server+profile_usecases 19/19, cargo build workspace Finished. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -26,6 +26,7 @@ pub mod input;
|
||||
pub mod inspector;
|
||||
pub mod issues;
|
||||
pub mod mailbox;
|
||||
pub mod model_server;
|
||||
pub mod orchestrator;
|
||||
pub mod permission;
|
||||
pub mod process;
|
||||
@ -64,6 +65,9 @@ pub use inspector::{
|
||||
};
|
||||
pub use issues::{FsIssueNumberAllocator, FsIssueStore};
|
||||
pub use mailbox::InMemoryMailbox;
|
||||
pub use model_server::{
|
||||
FsModelServerRegistry, HttpOpenAiCompatibleProbe, LlamaCppRuntime, LocalManagedProcess,
|
||||
};
|
||||
pub use orchestrator::mcp::{
|
||||
McpServer, MemoryTransport, StdioTransport, TicketToolError, TicketToolProvider,
|
||||
ToolPolicyRegistry,
|
||||
|
||||
315
crates/infrastructure/src/model_server/mod.rs
Normal file
315
crates/infrastructure/src/model_server/mod.rs
Normal file
@ -0,0 +1,315 @@
|
||||
//! Infrastructure adapters for local model servers.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::process::{Child, Command};
|
||||
|
||||
use domain::model_server::{LocalModelServerConfig, LocalModelServerKind, ModelServerEndpoint};
|
||||
use domain::ports::{
|
||||
FileSystem, ManagedProcess, ManagedProcessHandle, ModelServerError, ModelServerProbe,
|
||||
ModelServerRegistry, ModelServerRuntime, ProcessStatus, RemotePath, SpawnSpec,
|
||||
};
|
||||
use domain::{LocalModelServerId, ProjectPath};
|
||||
|
||||
/// HTTP readiness probe for OpenAI-compatible servers.
|
||||
#[derive(Clone)]
|
||||
pub struct HttpOpenAiCompatibleProbe {
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl Default for HttpOpenAiCompatibleProbe {
|
||||
fn default() -> Self {
|
||||
Self::new(Duration::from_secs(2))
|
||||
}
|
||||
}
|
||||
|
||||
impl HttpOpenAiCompatibleProbe {
|
||||
/// Builds the probe with a request timeout.
|
||||
#[must_use]
|
||||
pub fn new(timeout: Duration) -> Self {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(timeout)
|
||||
.build()
|
||||
.expect("reqwest client with timeout builds");
|
||||
Self { client }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ModelServerProbe for HttpOpenAiCompatibleProbe {
|
||||
async fn probe(
|
||||
&self,
|
||||
endpoint: &ModelServerEndpoint,
|
||||
) -> Result<domain::ModelServerStatus, ModelServerError> {
|
||||
let models = format!("{}/models", endpoint.base_url.trim_end_matches('/'));
|
||||
if is_ready(self.client.get(models).send().await) {
|
||||
return Ok(domain::ModelServerStatus::ReadyReused);
|
||||
}
|
||||
let root = endpoint.base_url.trim_end_matches("/v1").to_owned();
|
||||
if is_ready(self.client.get(root).send().await) {
|
||||
return Ok(domain::ModelServerStatus::ReadyReused);
|
||||
}
|
||||
Ok(domain::ModelServerStatus::Unreachable)
|
||||
}
|
||||
}
|
||||
|
||||
fn is_ready(result: Result<reqwest::Response, reqwest::Error>) -> bool {
|
||||
result
|
||||
.map(|response| response.status().is_success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Builds `llama-server` argv without shell interpolation.
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub struct LlamaCppRuntime;
|
||||
|
||||
impl LlamaCppRuntime {
|
||||
/// Creates the runtime.
|
||||
#[must_use]
|
||||
pub const fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
impl ModelServerRuntime for LlamaCppRuntime {
|
||||
fn build_spawn_spec(
|
||||
&self,
|
||||
config: &LocalModelServerConfig,
|
||||
) -> Result<SpawnSpec, ModelServerError> {
|
||||
if config.kind != LocalModelServerKind::LlamaCpp {
|
||||
return Err(ModelServerError::Invalid(
|
||||
"only LlamaCpp local model servers are supported".to_owned(),
|
||||
));
|
||||
}
|
||||
let model_path =
|
||||
config.model.path.as_ref().ok_or_else(|| {
|
||||
ModelServerError::PathNotAccessible("model.path missing".to_owned())
|
||||
})?;
|
||||
let command = resolve_binary(
|
||||
config
|
||||
.binary
|
||||
.as_ref()
|
||||
.map(|binary| binary.as_str())
|
||||
.unwrap_or("llama-server"),
|
||||
)?;
|
||||
let mut args = vec![
|
||||
"--model".to_owned(),
|
||||
model_path.as_str().to_owned(),
|
||||
"--port".to_owned(),
|
||||
config.endpoint.port.to_string(),
|
||||
];
|
||||
args.extend(config.args.clone());
|
||||
Ok(SpawnSpec {
|
||||
command,
|
||||
args,
|
||||
cwd: ProjectPath::new("/").map_err(|e| ModelServerError::Invalid(e.to_string()))?,
|
||||
env: Vec::new(),
|
||||
context_plan: None,
|
||||
sandbox: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_binary(raw: &str) -> Result<String, ModelServerError> {
|
||||
if is_path_like(raw) {
|
||||
let path = Path::new(raw);
|
||||
if path.is_file() {
|
||||
return Ok(raw.to_owned());
|
||||
}
|
||||
return Err(ModelServerError::PathNotAccessible(raw.to_owned()));
|
||||
}
|
||||
if let Some(path) = find_in_path(raw) {
|
||||
return Ok(path.to_string_lossy().into_owned());
|
||||
}
|
||||
Err(ModelServerError::PathNotAccessible(format!(
|
||||
"{raw} not found in PATH"
|
||||
)))
|
||||
}
|
||||
|
||||
fn is_path_like(raw: &str) -> bool {
|
||||
raw.contains('/') || raw.contains('\\') || Path::new(raw).is_absolute()
|
||||
}
|
||||
|
||||
fn find_in_path(command: &str) -> Option<PathBuf> {
|
||||
std::env::var_os("PATH").and_then(|path| {
|
||||
std::env::split_paths(&path)
|
||||
.map(|dir| dir.join(command))
|
||||
.find(|candidate| candidate.is_file())
|
||||
})
|
||||
}
|
||||
|
||||
/// Local child-process manager for long-lived model servers.
|
||||
#[derive(Default)]
|
||||
pub struct LocalManagedProcess {
|
||||
children: Mutex<HashMap<String, Child>>,
|
||||
}
|
||||
|
||||
impl LocalManagedProcess {
|
||||
/// Creates an empty process manager.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ManagedProcess for LocalManagedProcess {
|
||||
async fn spawn(&self, spec: SpawnSpec) -> Result<ManagedProcessHandle, ModelServerError> {
|
||||
let mut command = Command::new(&spec.command);
|
||||
command.args(&spec.args);
|
||||
if spec.cwd.as_str() != "/" {
|
||||
command.current_dir(spec.cwd.as_str());
|
||||
}
|
||||
for (key, value) in &spec.env {
|
||||
command.env(key, value);
|
||||
}
|
||||
let child = command
|
||||
.spawn()
|
||||
.map_err(|e| ModelServerError::Process(format!("{}: {e}", spec.command)))?;
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
self.children.lock().unwrap().insert(id.clone(), child);
|
||||
Ok(ManagedProcessHandle { id })
|
||||
}
|
||||
|
||||
async fn kill(&self, handle: &ManagedProcessHandle) -> Result<(), ModelServerError> {
|
||||
let Some(mut child) = self.children.lock().unwrap().remove(&handle.id) else {
|
||||
return Ok(());
|
||||
};
|
||||
child
|
||||
.start_kill()
|
||||
.map_err(|e| ModelServerError::Process(e.to_string()))
|
||||
}
|
||||
|
||||
async fn status(
|
||||
&self,
|
||||
handle: &ManagedProcessHandle,
|
||||
) -> Result<ProcessStatus, ModelServerError> {
|
||||
let mut children = self.children.lock().unwrap();
|
||||
let Some(child) = children.get_mut(&handle.id) else {
|
||||
return Ok(ProcessStatus::Unknown);
|
||||
};
|
||||
match child
|
||||
.try_wait()
|
||||
.map_err(|e| ModelServerError::Process(e.to_string()))?
|
||||
{
|
||||
Some(status) => Ok(ProcessStatus::Exited {
|
||||
code: status.code(),
|
||||
}),
|
||||
None => Ok(ProcessStatus::Running),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// File name of the global local-model-server registry.
|
||||
const MODEL_SERVERS_FILE: &str = "model-servers.json";
|
||||
const MODEL_SERVERS_VERSION: u32 = 1;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ModelServersDoc {
|
||||
version: u32,
|
||||
servers: Vec<LocalModelServerConfig>,
|
||||
}
|
||||
|
||||
impl Default for ModelServersDoc {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
version: MODEL_SERVERS_VERSION,
|
||||
servers: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Filesystem-backed global model-server registry.
|
||||
#[derive(Clone)]
|
||||
pub struct FsModelServerRegistry {
|
||||
fs: std::sync::Arc<dyn FileSystem>,
|
||||
app_data_dir: String,
|
||||
}
|
||||
|
||||
impl FsModelServerRegistry {
|
||||
/// Builds the registry from a filesystem and global app-data dir.
|
||||
#[must_use]
|
||||
pub fn new(fs: std::sync::Arc<dyn FileSystem>, app_data_dir: impl Into<String>) -> Self {
|
||||
Self {
|
||||
fs,
|
||||
app_data_dir: app_data_dir.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn path(&self) -> RemotePath {
|
||||
let base = self.app_data_dir.trim_end_matches(['/', '\\']);
|
||||
RemotePath::new(format!("{base}/{MODEL_SERVERS_FILE}"))
|
||||
}
|
||||
|
||||
async fn read_doc(&self) -> Result<ModelServersDoc, ModelServerError> {
|
||||
match self.fs.read(&self.path()).await {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes)
|
||||
.map_err(|e| ModelServerError::Store(format!("serialization failed: {e}"))),
|
||||
Err(domain::ports::FsError::NotFound(_)) => Ok(ModelServersDoc::default()),
|
||||
Err(domain::ports::FsError::PermissionDenied(p)) => {
|
||||
Err(ModelServerError::PermissionDenied(p))
|
||||
}
|
||||
Err(e) => Err(ModelServerError::Store(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
async fn write_doc(&self, doc: &ModelServersDoc) -> Result<(), ModelServerError> {
|
||||
let dir = RemotePath::new(self.app_data_dir.trim_end_matches(['/', '\\']).to_owned());
|
||||
self.fs.create_dir_all(&dir).await.map_err(|e| match e {
|
||||
domain::ports::FsError::PermissionDenied(p) => ModelServerError::PermissionDenied(p),
|
||||
other => ModelServerError::Store(other.to_string()),
|
||||
})?;
|
||||
let bytes = serde_json::to_vec_pretty(doc)
|
||||
.map_err(|e| ModelServerError::Store(format!("serialization failed: {e}")))?;
|
||||
self.fs
|
||||
.write(&self.path(), &bytes)
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
domain::ports::FsError::PermissionDenied(p) => {
|
||||
ModelServerError::PermissionDenied(p)
|
||||
}
|
||||
other => ModelServerError::Store(other.to_string()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ModelServerRegistry for FsModelServerRegistry {
|
||||
async fn get(
|
||||
&self,
|
||||
id: &LocalModelServerId,
|
||||
) -> Result<Option<LocalModelServerConfig>, ModelServerError> {
|
||||
Ok(self
|
||||
.read_doc()
|
||||
.await?
|
||||
.servers
|
||||
.into_iter()
|
||||
.find(|server| &server.id == id))
|
||||
}
|
||||
|
||||
async fn list(&self) -> Result<Vec<LocalModelServerConfig>, ModelServerError> {
|
||||
Ok(self.read_doc().await?.servers)
|
||||
}
|
||||
|
||||
async fn save(&self, config: LocalModelServerConfig) -> Result<(), ModelServerError> {
|
||||
let mut doc = self.read_doc().await?;
|
||||
if let Some(slot) = doc.servers.iter_mut().find(|server| server.id == config.id) {
|
||||
*slot = config;
|
||||
} else {
|
||||
doc.servers.push(config);
|
||||
}
|
||||
self.write_doc(&doc).await
|
||||
}
|
||||
|
||||
async fn delete(&self, id: LocalModelServerId) -> Result<(), ModelServerError> {
|
||||
let mut doc = self.read_doc().await?;
|
||||
doc.servers.retain(|server| server.id != id);
|
||||
self.write_doc(&doc).await
|
||||
}
|
||||
}
|
||||
111
crates/infrastructure/tests/model_server.rs
Normal file
111
crates/infrastructure/tests/model_server.rs
Normal file
@ -0,0 +1,111 @@
|
||||
//! Infrastructure tests for local model-server adapters.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::model_server::{
|
||||
ExecutablePath, LocalModelRef, LocalModelServerConfig, LocalModelServerKind, ModelPath,
|
||||
ModelServerEndpoint, StopPolicy,
|
||||
};
|
||||
use domain::ports::{FileSystem, ModelServerRegistry, ModelServerRuntime, RemotePath};
|
||||
use domain::LocalModelServerId;
|
||||
use infrastructure::{FsModelServerRegistry, 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())
|
||||
}
|
||||
}
|
||||
|
||||
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(ModelPath::new("/models/qwen.gguf").unwrap()),
|
||||
"qwen3-coder-30b",
|
||||
)
|
||||
.unwrap(),
|
||||
Some(ExecutablePath::new(binary).unwrap()),
|
||||
vec!["--ctx-size".to_owned(), "8192".to_owned()],
|
||||
true,
|
||||
StopPolicy::StopOnAppExit,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn llamacpp_runtime_builds_structured_argv() {
|
||||
let spec = LlamaCppRuntime::new()
|
||||
.build_spawn_spec(&config(sid(1), 8080))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
spec.command,
|
||||
std::env::current_exe().unwrap().to_string_lossy()
|
||||
);
|
||||
assert_eq!(
|
||||
spec.args,
|
||||
vec![
|
||||
"--model",
|
||||
"/models/qwen.gguf",
|
||||
"--port",
|
||||
"8080",
|
||||
"--ctx-size",
|
||||
"8192"
|
||||
]
|
||||
);
|
||||
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);
|
||||
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]);
|
||||
}
|
||||
Reference in New Issue
Block a user