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:
@ -3,7 +3,8 @@
|
||||
|
||||
use crate::conversation::ConversationParty;
|
||||
use crate::ids::{
|
||||
AgentId, IssueId, ProfileId, ProjectId, SessionId, SkillId, SprintId, TaskId, TemplateId,
|
||||
AgentId, IssueId, LocalModelServerId, ProfileId, ProjectId, SessionId, SkillId, SprintId,
|
||||
TaskId, TemplateId,
|
||||
};
|
||||
use crate::issue::{IssueLinkKind, IssuePriority, IssueRef, IssueStatus, IssueVersion};
|
||||
use crate::mailbox::TicketId;
|
||||
@ -46,6 +47,24 @@ pub enum DomainEvent {
|
||||
/// The session it runs in.
|
||||
session_id: SessionId,
|
||||
},
|
||||
/// An agent launch failed before a runtime session could be created.
|
||||
AgentLaunchFailed {
|
||||
/// The agent whose launch failed.
|
||||
agent_id: AgentId,
|
||||
/// Failure cause namespace.
|
||||
cause: String,
|
||||
/// Stable error code.
|
||||
code: String,
|
||||
/// Human-readable message.
|
||||
message: String,
|
||||
},
|
||||
/// A local model-server status changed while preparing a launch.
|
||||
ModelServerStatusChanged {
|
||||
/// The local model server.
|
||||
server_id: LocalModelServerId,
|
||||
/// New lifecycle status.
|
||||
status: crate::model_server::ModelServerLifecycleStatus,
|
||||
},
|
||||
/// A first-class background task started running.
|
||||
BackgroundTaskStarted {
|
||||
/// The owning project.
|
||||
|
||||
@ -68,6 +68,10 @@ typed_id!(
|
||||
/// Identifies an [`crate::profile::AgentProfile`].
|
||||
ProfileId
|
||||
);
|
||||
typed_id!(
|
||||
/// Identifies a local model server configuration.
|
||||
LocalModelServerId
|
||||
);
|
||||
typed_id!(
|
||||
/// Identifies a [`crate::skill::Skill`].
|
||||
SkillId
|
||||
|
||||
@ -49,6 +49,7 @@ pub mod mailbox;
|
||||
pub mod markdown;
|
||||
pub mod memory;
|
||||
pub mod memory_harvest;
|
||||
pub mod model_server;
|
||||
pub mod orchestrator;
|
||||
pub mod permission;
|
||||
pub mod ports;
|
||||
@ -72,8 +73,8 @@ mod validation;
|
||||
pub use error::DomainError;
|
||||
|
||||
pub use ids::{
|
||||
AgentId, IssueId, LayoutId, NodeId, ProfileId, ProjectId, ScheduleId, SessionId, SkillId,
|
||||
SprintId, TabId, TaskId, TemplateId, WindowId,
|
||||
AgentId, IssueId, LayoutId, LocalModelServerId, NodeId, ProfileId, ProjectId, ScheduleId,
|
||||
SessionId, SkillId, SprintId, TabId, TaskId, TemplateId, WindowId,
|
||||
};
|
||||
|
||||
pub use project::{Project, ProjectPath};
|
||||
@ -153,6 +154,12 @@ pub use memory_harvest::{
|
||||
MAX_BLOCK_BYTES, MAX_DESCRIPTION_CHARS,
|
||||
};
|
||||
|
||||
pub use model_server::{
|
||||
ExecutablePath, LocalModelRef, LocalModelServerConfig, LocalModelServerKind, ModelPath,
|
||||
ModelServerEndpoint, ModelServerLifecycleStatus, ModelServerReady, ModelServerStatus,
|
||||
StopPolicy,
|
||||
};
|
||||
|
||||
pub use remote::{RemoteKind, RemoteRef, SshAuth};
|
||||
|
||||
pub use terminal::{PtySize, SessionKind, SessionStatus, TerminalSession};
|
||||
|
||||
368
crates/domain/src/model_server.rs
Normal file
368
crates/domain/src/model_server.rs
Normal file
@ -0,0 +1,368 @@
|
||||
//! Local model-server configuration and status.
|
||||
//!
|
||||
//! These value objects describe a local OpenAI-compatible server that can back an
|
||||
//! OpenCode profile. They are pure persisted configuration: probing, process
|
||||
//! spawning and filesystem checks live behind ports.
|
||||
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::DomainError;
|
||||
use crate::ids::LocalModelServerId;
|
||||
|
||||
/// Local model server implementation family.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum LocalModelServerKind {
|
||||
/// `llama-server` from llama.cpp.
|
||||
LlamaCpp,
|
||||
}
|
||||
|
||||
/// HTTP endpoint for an OpenAI-compatible model server.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ModelServerEndpoint {
|
||||
/// Normalised base URL, always ending in `/v1`.
|
||||
#[serde(rename = "baseURL")]
|
||||
pub base_url: String,
|
||||
/// TCP port expected for the local server.
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
impl ModelServerEndpoint {
|
||||
/// Builds a validated endpoint and normalises its base URL to `/v1`.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`DomainError`] if the URL is blank, non-HTTP(S), or the port is 0.
|
||||
pub fn new(base_url: impl Into<String>, port: u16) -> Result<Self, DomainError> {
|
||||
let base_url = normalise_base_url(base_url.into())?;
|
||||
if port == 0 {
|
||||
return Err(DomainError::Invariant(
|
||||
"modelServer.endpoint.port must be in 1..=65535".to_owned(),
|
||||
));
|
||||
}
|
||||
Ok(Self { base_url, port })
|
||||
}
|
||||
}
|
||||
|
||||
fn normalise_base_url(raw: String) -> Result<String, DomainError> {
|
||||
let trimmed = raw.trim().trim_end_matches('/').to_owned();
|
||||
crate::validation::non_empty(&trimmed, "modelServer.endpoint.baseURL")?;
|
||||
if !(trimmed.starts_with("http://") || trimmed.starts_with("https://")) {
|
||||
return Err(DomainError::Invariant(
|
||||
"modelServer.endpoint.baseURL must start with http:// or https://".to_owned(),
|
||||
));
|
||||
}
|
||||
if !has_local_host_authority(&trimmed) {
|
||||
return Err(DomainError::Invariant(
|
||||
"modelServer.endpoint.baseURL must target localhost in V1".to_owned(),
|
||||
));
|
||||
}
|
||||
if trimmed.ends_with("/v1") {
|
||||
Ok(trimmed)
|
||||
} else {
|
||||
Ok(format!("{trimmed}/v1"))
|
||||
}
|
||||
}
|
||||
|
||||
fn has_local_host_authority(url: &str) -> bool {
|
||||
let Some(authority_and_path) = url.split_once("://").map(|(_, rest)| rest) else {
|
||||
return false;
|
||||
};
|
||||
let authority = authority_and_path.split('/').next().unwrap_or_default();
|
||||
let host = authority
|
||||
.rsplit_once('@')
|
||||
.map(|(_, host)| host)
|
||||
.unwrap_or(authority);
|
||||
let host = host
|
||||
.strip_prefix('[')
|
||||
.and_then(|ipv6| ipv6.split_once(']').map(|(addr, _)| addr))
|
||||
.unwrap_or_else(|| host.split(':').next().unwrap_or_default());
|
||||
host.eq_ignore_ascii_case("localhost") || host == "::1" || is_ipv4_loopback(host)
|
||||
}
|
||||
|
||||
fn is_ipv4_loopback(host: &str) -> bool {
|
||||
host.parse::<Ipv4Addr>()
|
||||
.map(|addr| addr.octets()[0] == 127)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Absolute local path to a model file.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct ModelPath(pub String);
|
||||
|
||||
impl ModelPath {
|
||||
/// Builds a validated absolute model path.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`DomainError`] if the path is blank or relative.
|
||||
pub fn new(path: impl Into<String>) -> Result<Self, DomainError> {
|
||||
let path = path.into();
|
||||
crate::validation::non_empty(&path, "modelServer.model.path")?;
|
||||
if !is_absolute_path(&path) {
|
||||
return Err(DomainError::Invariant(
|
||||
"modelServer.model.path must be absolute".to_owned(),
|
||||
));
|
||||
}
|
||||
Ok(Self(path))
|
||||
}
|
||||
|
||||
/// Returns the path 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)]
|
||||
pub struct ExecutablePath(pub String);
|
||||
|
||||
impl ExecutablePath {
|
||||
/// Builds a validated executable path.
|
||||
///
|
||||
/// Absolute paths are accepted directly. Bare command names are accepted so
|
||||
/// infrastructure can resolve them through `PATH` without shell interpolation.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`DomainError`] if the path is blank.
|
||||
pub fn new(path: impl Into<String>) -> Result<Self, DomainError> {
|
||||
let path = path.into();
|
||||
crate::validation::non_empty(&path, "modelServer.binary")?;
|
||||
Ok(Self(path))
|
||||
}
|
||||
|
||||
/// Returns the path as a string slice.
|
||||
#[must_use]
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
fn is_absolute_path(path: &str) -> bool {
|
||||
path.starts_with('/')
|
||||
|| path.starts_with('\\')
|
||||
|| (path.len() >= 3
|
||||
&& path.as_bytes()[1] == b':'
|
||||
&& path.as_bytes()[0].is_ascii_alphabetic()
|
||||
&& matches!(path.as_bytes()[2], b'/' | b'\\'))
|
||||
}
|
||||
|
||||
/// Model served by a local model server.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LocalModelRef {
|
||||
/// Stable model id in IdeA configuration.
|
||||
pub id: String,
|
||||
/// Human display label.
|
||||
pub label: String,
|
||||
/// Explicit absolute `.gguf` path. Required for auto-start.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<ModelPath>,
|
||||
/// Name exposed by the server and used in `OpenCodeConfig.model`.
|
||||
pub served_name: String,
|
||||
}
|
||||
|
||||
impl LocalModelRef {
|
||||
/// Builds a validated local model reference.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`DomainError`] if `id`, `label` or `served_name` is blank.
|
||||
pub fn new(
|
||||
id: impl Into<String>,
|
||||
label: impl Into<String>,
|
||||
path: Option<ModelPath>,
|
||||
served_name: impl Into<String>,
|
||||
) -> Result<Self, DomainError> {
|
||||
let id = id.into();
|
||||
let label = label.into();
|
||||
let served_name = served_name.into();
|
||||
crate::validation::non_empty(&id, "modelServer.model.id")?;
|
||||
crate::validation::non_empty(&label, "modelServer.model.label")?;
|
||||
crate::validation::non_empty(&served_name, "modelServer.model.servedName")?;
|
||||
Ok(Self {
|
||||
id,
|
||||
label,
|
||||
path,
|
||||
served_name,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop policy for a managed local model server.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum StopPolicy {
|
||||
/// Leave the process running.
|
||||
KeepAlive,
|
||||
/// Stop when no profile uses it. Not used aggressively in V1.
|
||||
StopWhenUnused,
|
||||
/// Stop on application shutdown.
|
||||
StopOnAppExit,
|
||||
}
|
||||
|
||||
/// Persisted configuration for a local model server.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LocalModelServerConfig {
|
||||
/// Stable identity.
|
||||
pub id: LocalModelServerId,
|
||||
/// Server implementation family.
|
||||
pub kind: LocalModelServerKind,
|
||||
/// Display name.
|
||||
pub name: String,
|
||||
/// OpenAI-compatible endpoint.
|
||||
pub endpoint: ModelServerEndpoint,
|
||||
/// Served model.
|
||||
pub model: LocalModelRef,
|
||||
/// Optional executable path or bare command name.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub binary: Option<ExecutablePath>,
|
||||
/// Extra argv passed to the runtime.
|
||||
#[serde(default)]
|
||||
pub args: Vec<String>,
|
||||
/// Whether IdeA may start the server lazily.
|
||||
pub auto_start: bool,
|
||||
/// Process stop policy.
|
||||
#[serde(default = "default_stop_policy")]
|
||||
pub stop_policy: StopPolicy,
|
||||
}
|
||||
|
||||
fn default_stop_policy() -> StopPolicy {
|
||||
StopPolicy::StopOnAppExit
|
||||
}
|
||||
|
||||
impl LocalModelServerConfig {
|
||||
/// Builds a validated local server configuration.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`DomainError`] if names are blank or auto-start invariants are not
|
||||
/// satisfied by static data.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
id: LocalModelServerId,
|
||||
kind: LocalModelServerKind,
|
||||
name: impl Into<String>,
|
||||
endpoint: ModelServerEndpoint,
|
||||
model: LocalModelRef,
|
||||
binary: Option<ExecutablePath>,
|
||||
args: Vec<String>,
|
||||
auto_start: bool,
|
||||
stop_policy: StopPolicy,
|
||||
) -> Result<Self, DomainError> {
|
||||
let name = name.into();
|
||||
crate::validation::non_empty(&name, "modelServer.name")?;
|
||||
if auto_start {
|
||||
if kind != LocalModelServerKind::LlamaCpp {
|
||||
return Err(DomainError::Invariant(
|
||||
"modelServer.autoStart requires kind=LlamaCpp".to_owned(),
|
||||
));
|
||||
}
|
||||
if model.path.is_none() {
|
||||
return Err(DomainError::Invariant(
|
||||
"modelServer.autoStart requires model.path".to_owned(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(Self {
|
||||
id,
|
||||
kind,
|
||||
name,
|
||||
endpoint,
|
||||
model,
|
||||
binary,
|
||||
args,
|
||||
auto_start,
|
||||
stop_policy,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Observed model-server status.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ModelServerStatus {
|
||||
/// The server is reachable and was already running.
|
||||
ReadyReused,
|
||||
/// The server is reachable after IdeA started it.
|
||||
ReadyStarted,
|
||||
/// The server is not reachable.
|
||||
Unreachable,
|
||||
}
|
||||
|
||||
/// Successful ensure result used by agent launch.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ModelServerReady {
|
||||
/// Base URL to use in OpenCode config.
|
||||
pub base_url: String,
|
||||
/// Served model name to use in OpenCode config.
|
||||
pub model: String,
|
||||
/// Whether the server was reused or started.
|
||||
pub status: ModelServerStatus,
|
||||
}
|
||||
|
||||
/// Lifecycle status emitted for presentation.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ModelServerLifecycleStatus {
|
||||
/// No local model server is configured for the profile.
|
||||
NotConfigured,
|
||||
/// A readiness probe is in progress.
|
||||
Probing,
|
||||
/// IdeA is starting the server.
|
||||
Starting,
|
||||
/// The server is ready.
|
||||
Ready {
|
||||
/// `true` when an already-running server was reused.
|
||||
reused: bool,
|
||||
},
|
||||
/// The server failed to become ready.
|
||||
Failed {
|
||||
/// Stable error code.
|
||||
code: String,
|
||||
/// Human-readable message.
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn endpoint_normalises_to_v1() {
|
||||
let endpoint = ModelServerEndpoint::new("http://localhost:8080", 8080).unwrap();
|
||||
assert_eq!(endpoint.base_url, "http://localhost:8080/v1");
|
||||
let endpoint = ModelServerEndpoint::new("http://localhost:8080/v1/", 8080).unwrap();
|
||||
assert_eq!(endpoint.base_url, "http://localhost:8080/v1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
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();
|
||||
assert!(err.to_string().contains("localhost"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_start_requires_model_path() {
|
||||
let endpoint = ModelServerEndpoint::new("http://localhost:8080/v1", 8080).unwrap();
|
||||
let model = LocalModelRef::new("qwen", "Qwen", None, "qwen").unwrap();
|
||||
let err = LocalModelServerConfig::new(
|
||||
LocalModelServerId::from_uuid(uuid::Uuid::nil()),
|
||||
LocalModelServerKind::LlamaCpp,
|
||||
"llama.cpp",
|
||||
endpoint,
|
||||
model,
|
||||
None,
|
||||
Vec::new(),
|
||||
true,
|
||||
StopPolicy::StopOnAppExit,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("model.path"));
|
||||
}
|
||||
}
|
||||
@ -34,12 +34,15 @@ use crate::background_task::{
|
||||
BackgroundTask, BackgroundTaskKind, BackgroundTaskResult, BackgroundTaskWakePolicy,
|
||||
};
|
||||
use crate::events::DomainEvent;
|
||||
use crate::ids::{AgentId, NodeId, ProjectId, ScheduleId, SessionId, SprintId, TaskId};
|
||||
use crate::ids::{
|
||||
AgentId, LocalModelServerId, NodeId, ProjectId, ScheduleId, SessionId, SprintId, TaskId,
|
||||
};
|
||||
use crate::issue::{
|
||||
Issue, IssueCarnet, IssueIndexEntry, IssueListFilter, IssueNumber, IssueRef, IssueVersion,
|
||||
};
|
||||
use crate::markdown::MarkdownDoc;
|
||||
use crate::memory::{Memory, MemoryIndexEntry, MemoryLink, MemorySlug};
|
||||
use crate::model_server::{LocalModelServerConfig, ModelServerEndpoint, ModelServerStatus};
|
||||
use crate::permission::ProjectPermissions;
|
||||
use crate::profile::{AgentProfile, EmbedderProfile};
|
||||
use crate::project::{Project, ProjectPath};
|
||||
@ -206,6 +209,27 @@ pub struct Output {
|
||||
pub stderr: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Opaque handle for a managed local process.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct ManagedProcessHandle {
|
||||
/// Stable local process identity as assigned by the adapter.
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
/// Observed status of a managed local process.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ProcessStatus {
|
||||
/// Process is still running.
|
||||
Running,
|
||||
/// Process has exited.
|
||||
Exited {
|
||||
/// Exit code, when available.
|
||||
code: Option<i32>,
|
||||
},
|
||||
/// Handle is unknown to the adapter.
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// A location-neutral path used by [`FileSystem`] (local, SFTP, or WSL).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct RemotePath(pub String);
|
||||
@ -502,6 +526,41 @@ pub enum ProcessError {
|
||||
Io(String),
|
||||
}
|
||||
|
||||
/// Errors from local model-server ports.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||
pub enum ModelServerError {
|
||||
/// The referenced configuration does not exist.
|
||||
#[error("not configured")]
|
||||
NotConfigured,
|
||||
/// Server or configuration is invalid.
|
||||
#[error("invalid: {0}")]
|
||||
Invalid(String),
|
||||
/// Filesystem permission denied.
|
||||
#[error("permission denied: {0}")]
|
||||
PermissionDenied(String),
|
||||
/// Required path is not accessible.
|
||||
#[error("path not accessible: {0}")]
|
||||
PathNotAccessible(String),
|
||||
/// The configured port is already in use by another managed server.
|
||||
#[error("port occupied: {0}")]
|
||||
PortOccupied(u16),
|
||||
/// The server is still referenced by another configuration object.
|
||||
#[error("model server in use: {0}")]
|
||||
InUse(String),
|
||||
/// Readiness probe failed unexpectedly.
|
||||
#[error("probe failed: {0}")]
|
||||
Probe(String),
|
||||
/// Process operation failed.
|
||||
#[error("process failed: {0}")]
|
||||
Process(String),
|
||||
/// Store operation failed.
|
||||
#[error("store failed: {0}")]
|
||||
Store(String),
|
||||
/// Readiness timed out.
|
||||
#[error("readiness timed out")]
|
||||
Timeout,
|
||||
}
|
||||
|
||||
/// Errors from [`FileSystem`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||
pub enum FsError {
|
||||
@ -914,6 +973,88 @@ pub trait ProcessSpawner: Send + Sync {
|
||||
async fn run(&self, spec: SpawnSpec) -> Result<Output, ProcessError>;
|
||||
}
|
||||
|
||||
/// Probe readiness of an OpenAI-compatible model server.
|
||||
#[async_trait]
|
||||
pub trait ModelServerProbe: Send + Sync {
|
||||
/// Probes `endpoint` and returns the observed status.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`ModelServerError`] on unexpected probe failure. An unreachable server
|
||||
/// should normally return [`ModelServerStatus::Unreachable`].
|
||||
async fn probe(
|
||||
&self,
|
||||
endpoint: &ModelServerEndpoint,
|
||||
) -> Result<ModelServerStatus, ModelServerError>;
|
||||
}
|
||||
|
||||
/// Manages local long-lived child processes.
|
||||
#[async_trait]
|
||||
pub trait ManagedProcess: Send + Sync {
|
||||
/// Spawns a process from an argv-structured spec.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`ModelServerError`] on spawn failure.
|
||||
async fn spawn(&self, spec: SpawnSpec) -> Result<ManagedProcessHandle, ModelServerError>;
|
||||
|
||||
/// Kills a process.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`ModelServerError`] on kill failure.
|
||||
async fn kill(&self, handle: &ManagedProcessHandle) -> Result<(), ModelServerError>;
|
||||
|
||||
/// Returns the status of a process.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`ModelServerError`] on status failure.
|
||||
async fn status(
|
||||
&self,
|
||||
handle: &ManagedProcessHandle,
|
||||
) -> Result<ProcessStatus, ModelServerError>;
|
||||
}
|
||||
|
||||
/// Builds the argv-structured spawn spec for a local model server.
|
||||
pub trait ModelServerRuntime: Send + Sync {
|
||||
/// Builds a spawn spec from a validated local-server config.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`ModelServerError`] if the config cannot be launched.
|
||||
fn build_spawn_spec(
|
||||
&self,
|
||||
config: &LocalModelServerConfig,
|
||||
) -> Result<SpawnSpec, ModelServerError>;
|
||||
}
|
||||
|
||||
/// Persists local model-server configuration in the global IdeA store.
|
||||
#[async_trait]
|
||||
pub trait ModelServerRegistry: Send + Sync {
|
||||
/// Returns one server config by id.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`ModelServerError`] on store failure.
|
||||
async fn get(
|
||||
&self,
|
||||
id: &LocalModelServerId,
|
||||
) -> Result<Option<LocalModelServerConfig>, ModelServerError>;
|
||||
|
||||
/// Lists all server configs.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`ModelServerError`] on store failure.
|
||||
async fn list(&self) -> Result<Vec<LocalModelServerConfig>, ModelServerError>;
|
||||
|
||||
/// Saves one server config.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`ModelServerError`] on store failure.
|
||||
async fn save(&self, config: LocalModelServerConfig) -> Result<(), ModelServerError>;
|
||||
|
||||
/// Deletes one server config.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`ModelServerError`] on store failure.
|
||||
async fn delete(&self, id: LocalModelServerId) -> Result<(), ModelServerError>;
|
||||
}
|
||||
|
||||
/// Location-neutral filesystem access.
|
||||
#[async_trait]
|
||||
pub trait FileSystem: Send + Sync {
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::DomainError;
|
||||
use crate::ids::ProfileId;
|
||||
use crate::ids::{LocalModelServerId, ProfileId};
|
||||
use crate::permission::ProjectorKey;
|
||||
|
||||
/// Strategy for injecting an agent's `.md` context into the launched CLI.
|
||||
@ -295,6 +295,11 @@ pub struct OpenCodeConfig {
|
||||
/// Active les attachments côté modèle OpenCode. Défaut effectif : `false`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub attachment: Option<bool>,
|
||||
/// Serveur local managé associé, quand ce profil dépend d'un lifecycle IdeA.
|
||||
///
|
||||
/// `None` garde le comportement existant : profil OpenCode manuel/externe.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub local_model_server_id: Option<LocalModelServerId>,
|
||||
}
|
||||
|
||||
impl OpenCodeConfig {
|
||||
@ -325,9 +330,17 @@ impl OpenCodeConfig {
|
||||
model,
|
||||
reasoning,
|
||||
attachment,
|
||||
local_model_server_id: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Builder : associe cette configuration à un serveur local managé.
|
||||
#[must_use]
|
||||
pub const fn with_local_model_server_id(mut self, id: LocalModelServerId) -> Self {
|
||||
self.local_model_server_id = Some(id);
|
||||
self
|
||||
}
|
||||
|
||||
/// Valeur effective de `reasoning` quand le profil ne la porte pas.
|
||||
#[must_use]
|
||||
pub fn reasoning_enabled(&self) -> bool {
|
||||
@ -1107,7 +1120,7 @@ impl AgentProfile {
|
||||
#[cfg(test)]
|
||||
mod mcp_tests {
|
||||
use super::*;
|
||||
use crate::ids::ProfileId;
|
||||
use crate::ids::{LocalModelServerId, ProfileId};
|
||||
|
||||
/// A reference profile without any MCP capability (the historical shape).
|
||||
fn profile_without_mcp() -> AgentProfile {
|
||||
@ -1225,6 +1238,46 @@ mod mcp_tests {
|
||||
assert!(OpenCodeConfig::new("http://localhost:8080/v1", None, " ", None, None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opencode_config_omits_local_model_server_id_when_absent() {
|
||||
let config = OpenCodeConfig::new(
|
||||
"http://localhost:8080/v1",
|
||||
None,
|
||||
"qwen3-coder-30b",
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let json = serde_json::to_string(&config).expect("serialise");
|
||||
assert!(
|
||||
!json.contains("localModelServerId"),
|
||||
"manual external OpenCode profiles must keep the legacy JSON shape: {json}"
|
||||
);
|
||||
let back: OpenCodeConfig = serde_json::from_str(&json).expect("deserialise");
|
||||
assert_eq!(back.local_model_server_id, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opencode_config_serialises_local_model_server_id_camelcase() {
|
||||
let server_id = LocalModelServerId::from_uuid(uuid::Uuid::from_u128(35));
|
||||
let config = OpenCodeConfig::new(
|
||||
"http://localhost:8080/v1",
|
||||
None,
|
||||
"qwen3-coder-30b",
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap()
|
||||
.with_local_model_server_id(server_id);
|
||||
|
||||
let value = serde_json::to_value(&config).expect("serialise");
|
||||
assert_eq!(value["localModelServerId"], server_id.to_string());
|
||||
assert!(value.get("local_model_server_id").is_none());
|
||||
let back: OpenCodeConfig = serde_json::from_value(value).expect("deserialise");
|
||||
assert_eq!(back.local_model_server_id, Some(server_id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_compatible_stays_non_mcp_native_even_with_mcp_declared() {
|
||||
let profile = profile_without_mcp()
|
||||
|
||||
Reference in New Issue
Block a user