//! 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, port: u16) -> Result { 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 { 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::() .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) -> Result { 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) -> Result { 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, /// 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, label: impl Into, path: Option, served_name: impl Into, ) -> Result { 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, /// Extra argv passed to the runtime. #[serde(default)] pub args: Vec, /// 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, endpoint: ModelServerEndpoint, model: LocalModelRef, binary: Option, args: Vec, auto_start: bool, stop_policy: StopPolicy, ) -> Result { 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")); } }