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:
@ -733,11 +733,12 @@ pub struct SetActiveLayoutRequestDto {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
use application::{
|
||||
ConfigureProfilesInput, ConfigureProfilesOutput, DeleteProfileInput, DetectProfilesInput,
|
||||
DetectProfilesOutput, FirstRunStateOutput, ListProfilesOutput, ProfileAvailability,
|
||||
ReferenceProfilesOutput, SaveProfileInput, SaveProfileOutput,
|
||||
CloneOpenCodeProfileFromSeedInput, CloneOpenCodeProfileFromSeedOutput, ConfigureProfilesInput,
|
||||
ConfigureProfilesOutput, DeleteProfileInput, DetectProfilesInput, DetectProfilesOutput,
|
||||
FirstRunStateOutput, ListProfilesOutput, ProfileAvailability, ReferenceProfilesOutput,
|
||||
SaveProfileInput, SaveProfileOutput,
|
||||
};
|
||||
use domain::profile::AgentProfile;
|
||||
use domain::profile::{AgentProfile, OpenCodeConfig};
|
||||
use domain::ProfileId;
|
||||
|
||||
/// A profile crossing the wire. [`AgentProfile`] already serialises camelCase
|
||||
@ -776,6 +777,12 @@ impl From<SaveProfileOutput> for ProfileDto {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<CloneOpenCodeProfileFromSeedOutput> for ProfileDto {
|
||||
fn from(out: CloneOpenCodeProfileFromSeedOutput) -> Self {
|
||||
Self(out.profile)
|
||||
}
|
||||
}
|
||||
|
||||
/// Request DTO for `detect_profiles`: the candidate profiles to probe.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@ -838,6 +845,27 @@ impl From<SaveProfileRequestDto> for SaveProfileInput {
|
||||
}
|
||||
}
|
||||
|
||||
/// Request DTO for `clone_opencode_profile_from_seed`.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CloneOpenCodeProfileFromSeedRequestDto {
|
||||
/// Optional display name for the new profile.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
/// Optional OpenCode config override. When omitted, the seed config is copied.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub opencode: Option<OpenCodeConfig>,
|
||||
}
|
||||
|
||||
impl From<CloneOpenCodeProfileFromSeedRequestDto> for CloneOpenCodeProfileFromSeedInput {
|
||||
fn from(dto: CloneOpenCodeProfileFromSeedRequestDto) -> Self {
|
||||
Self {
|
||||
name: dto.name,
|
||||
opencode: dto.opencode,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Request DTO for `configure_profiles` (closes the first run).
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@ -879,6 +907,252 @@ impl From<FirstRunStateOutput> for FirstRunStateDto {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Local model servers (B35)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
use application::{ListModelServersOutput, SaveModelServerInput, SaveModelServerOutput};
|
||||
use domain::model_server::{
|
||||
ExecutablePath, LocalModelRef, LocalModelServerConfig, LocalModelServerKind, ModelPath,
|
||||
ModelServerEndpoint,
|
||||
};
|
||||
use domain::{LocalModelServerId, StopPolicy};
|
||||
|
||||
/// Local model-server implementation on the IPC wire.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ModelServerKindDto {
|
||||
/// llama.cpp `llama-server`.
|
||||
LlamaCpp,
|
||||
}
|
||||
|
||||
impl From<LocalModelServerKind> for ModelServerKindDto {
|
||||
fn from(kind: LocalModelServerKind) -> Self {
|
||||
match kind {
|
||||
LocalModelServerKind::LlamaCpp => Self::LlamaCpp,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ModelServerKindDto> for LocalModelServerKind {
|
||||
fn from(kind: ModelServerKindDto) -> Self {
|
||||
match kind {
|
||||
ModelServerKindDto::LlamaCpp => Self::LlamaCpp,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop policy on the IPC wire.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum StopPolicyDto {
|
||||
/// Keep the process alive.
|
||||
KeepAlive,
|
||||
/// Stop when no longer used.
|
||||
StopWhenUnused,
|
||||
/// Stop when the application exits.
|
||||
StopOnAppExit,
|
||||
}
|
||||
|
||||
impl From<StopPolicy> for StopPolicyDto {
|
||||
fn from(policy: StopPolicy) -> Self {
|
||||
match policy {
|
||||
StopPolicy::KeepAlive => Self::KeepAlive,
|
||||
StopPolicy::StopWhenUnused => Self::StopWhenUnused,
|
||||
StopPolicy::StopOnAppExit => Self::StopOnAppExit,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<StopPolicyDto> for StopPolicy {
|
||||
fn from(policy: StopPolicyDto) -> Self {
|
||||
match policy {
|
||||
StopPolicyDto::KeepAlive => Self::KeepAlive,
|
||||
StopPolicyDto::StopWhenUnused => Self::StopWhenUnused,
|
||||
StopPolicyDto::StopOnAppExit => Self::StopOnAppExit,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A local model-server config crossing the wire.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ModelServerConfigDto {
|
||||
/// Stable local server config id.
|
||||
pub id: String,
|
||||
/// Server implementation kind.
|
||||
pub kind: ModelServerKindDto,
|
||||
/// Display name.
|
||||
pub name: String,
|
||||
/// OpenAI-compatible base URL.
|
||||
#[serde(rename = "baseURL")]
|
||||
pub base_url: String,
|
||||
/// TCP port.
|
||||
pub port: u16,
|
||||
/// Optional explicit `.gguf` model path.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub model_path: Option<String>,
|
||||
/// Served model name exposed to OpenCode.
|
||||
pub served_model_name: String,
|
||||
/// Optional explicit `llama-server` executable path.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub binary_path: Option<String>,
|
||||
/// Extra argv entries.
|
||||
#[serde(default)]
|
||||
pub args: Vec<String>,
|
||||
/// Whether IdeA should start the server lazily.
|
||||
pub auto_start: bool,
|
||||
/// Stop policy.
|
||||
pub stop_policy: StopPolicyDto,
|
||||
}
|
||||
|
||||
impl ModelServerConfigDto {
|
||||
/// Maps a domain config to the flat IPC DTO.
|
||||
#[must_use]
|
||||
pub fn from_domain(config: LocalModelServerConfig) -> Self {
|
||||
Self {
|
||||
id: config.id.to_string(),
|
||||
kind: config.kind.into(),
|
||||
name: config.name,
|
||||
base_url: config.endpoint.base_url,
|
||||
port: config.endpoint.port,
|
||||
model_path: config
|
||||
.model
|
||||
.path
|
||||
.map(|path| path.as_str().to_owned()),
|
||||
served_model_name: config.model.served_name,
|
||||
binary_path: config.binary.map(|binary| binary.as_str().to_owned()),
|
||||
args: config.args,
|
||||
auto_start: config.auto_start,
|
||||
stop_policy: config.stop_policy.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps the flat IPC DTO into the domain config, preserving internal model id
|
||||
/// from the existing config when available.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`ErrorDto`] with `INVALID` code if any domain invariant rejects the input.
|
||||
pub fn into_domain(
|
||||
self,
|
||||
existing: Option<&LocalModelServerConfig>,
|
||||
) -> Result<LocalModelServerConfig, ErrorDto> {
|
||||
let server_id = parse_model_server_id(&self.id)?;
|
||||
let endpoint = ModelServerEndpoint::new(self.base_url.clone(), self.port)
|
||||
.map_err(invalid_domain_error)?;
|
||||
let model_path = optional_non_empty(self.model_path)
|
||||
.map(ModelPath::new)
|
||||
.transpose()
|
||||
.map_err(invalid_domain_error)?;
|
||||
let model_id = existing
|
||||
.filter(|config| config.id == server_id)
|
||||
.map(|config| config.model.id.clone())
|
||||
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
|
||||
let label = non_empty_or_fallback(&self.served_model_name, &self.name);
|
||||
let model = LocalModelRef::new(
|
||||
model_id,
|
||||
label,
|
||||
model_path,
|
||||
self.served_model_name.clone(),
|
||||
)
|
||||
.map_err(invalid_domain_error)?;
|
||||
let binary = optional_non_empty(self.binary_path)
|
||||
.map(ExecutablePath::new)
|
||||
.transpose()
|
||||
.map_err(invalid_domain_error)?;
|
||||
LocalModelServerConfig::new(
|
||||
server_id,
|
||||
self.kind.into(),
|
||||
self.name,
|
||||
endpoint,
|
||||
model,
|
||||
binary,
|
||||
self.args,
|
||||
self.auto_start,
|
||||
self.stop_policy.into(),
|
||||
)
|
||||
.map_err(invalid_domain_error)
|
||||
}
|
||||
}
|
||||
|
||||
/// List response for local model servers.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct ModelServerConfigListDto(pub Vec<ModelServerConfigDto>);
|
||||
|
||||
impl From<ListModelServersOutput> for ModelServerConfigListDto {
|
||||
fn from(out: ListModelServersOutput) -> Self {
|
||||
Self(
|
||||
out.servers
|
||||
.into_iter()
|
||||
.map(ModelServerConfigDto::from_domain)
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Request DTO for `save_model_server`.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SaveModelServerRequestDto {
|
||||
/// Config to upsert.
|
||||
pub config: ModelServerConfigDto,
|
||||
}
|
||||
|
||||
impl From<SaveModelServerOutput> for ModelServerConfigDto {
|
||||
fn from(out: SaveModelServerOutput) -> Self {
|
||||
Self::from_domain(out.config)
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses a local model-server id string.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`ErrorDto`] with `INVALID` code if the string is not a UUID.
|
||||
pub fn parse_model_server_id(raw: &str) -> Result<LocalModelServerId, ErrorDto> {
|
||||
uuid::Uuid::parse_str(raw)
|
||||
.map(LocalModelServerId::from_uuid)
|
||||
.map_err(|_| ErrorDto {
|
||||
code: "INVALID".to_owned(),
|
||||
message: format!("invalid model server id: {raw}"),
|
||||
})
|
||||
}
|
||||
|
||||
/// Builds a save-model-server input after the caller has resolved the existing
|
||||
/// config, if any.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`ErrorDto`] if DTO-to-domain validation fails.
|
||||
pub fn save_model_server_input(
|
||||
request: SaveModelServerRequestDto,
|
||||
existing: Option<&LocalModelServerConfig>,
|
||||
) -> Result<SaveModelServerInput, ErrorDto> {
|
||||
Ok(SaveModelServerInput {
|
||||
config: request.config.into_domain(existing)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn optional_non_empty(raw: Option<String>) -> Option<String> {
|
||||
raw.map(|value| value.trim().to_owned())
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn non_empty_or_fallback(primary: &str, fallback: &str) -> String {
|
||||
let primary = primary.trim();
|
||||
if primary.is_empty() {
|
||||
fallback.trim().to_owned()
|
||||
} else {
|
||||
primary.to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
fn invalid_domain_error(error: impl std::fmt::Display) -> ErrorDto {
|
||||
ErrorDto {
|
||||
code: "INVALID".to_owned(),
|
||||
message: error.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Embedder profiles & engines (LOT C2 — §14.5.3)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user