feat(model-server): source Hugging Face (-hf) + options structurées llama.cpp + migration store V2
Backend du support natif d'une source modèle Hugging Face en alternative au
chemin .gguf local pour le serveur llama.cpp intégré, et refonte des options.
- domaine : ModelSource {LocalPath|HuggingFace} + HfModelRef,
LlamaCppOptions {host,gpu_layers,context_size,jinja}, invariant auto_start
exigeant une source, validate_free_args (rejet des flags réservés).
- infra : build_argv partagé avec build_spawn_spec, émission -hf vs --model,
ordre argv figé ; migration model-servers.json V1 -> V2.
- app-tauri : DTO V2 (modelSource, compat modelPath, conflit = INVALID),
commande preview_model_server_command.
QA : cargo test ciblés verts (domain 7, application 8, infra model_server 4,
app-tauri dto 5). Échecs des suites complètes infra/app-tauri environnementaux
(sandbox bind/socket), hors périmètre.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -26,12 +26,14 @@ use application::{
|
|||||||
UnassignSkillFromAgentInput, UpdateAgentContextInput, UpdateAgentPermissionsInput,
|
UnassignSkillFromAgentInput, UpdateAgentContextInput, UpdateAgentPermissionsInput,
|
||||||
UpdateMemoryInput, UpdateProjectContextInput, UpdateProjectPermissionsInput, UpdateSkillInput,
|
UpdateMemoryInput, UpdateProjectContextInput, UpdateProjectPermissionsInput, UpdateSkillInput,
|
||||||
};
|
};
|
||||||
|
use domain::ports::ModelServerRuntime;
|
||||||
use domain::ports::PtyHandle;
|
use domain::ports::PtyHandle;
|
||||||
|
|
||||||
use crate::dto::{
|
use crate::dto::{
|
||||||
parse_agent_id, parse_close_terminal, parse_delete_profile, parse_layout_id, parse_memory_slug,
|
model_server_config_domain, parse_agent_id, parse_close_terminal, parse_delete_profile,
|
||||||
parse_node_id, parse_profile_id, parse_project_id, parse_session_id, parse_skill_id,
|
parse_layout_id, parse_memory_slug, parse_model_server_id, parse_node_id, parse_profile_id,
|
||||||
parse_task_id, parse_template_id, parse_ticket_id, AgentDriftListDto, AgentDto, AgentListDto,
|
parse_project_id, parse_session_id, parse_skill_id, parse_task_id, parse_template_id,
|
||||||
|
parse_ticket_id, save_model_server_input, AgentDriftListDto, AgentDto, AgentListDto,
|
||||||
AssignSkillRequestDto, AttachLiveAgentRequestDto, AttachLiveAgentResponseDto,
|
AssignSkillRequestDto, AttachLiveAgentRequestDto, AttachLiveAgentResponseDto,
|
||||||
BackgroundTaskDto, ChangeAgentProfileDto, ChangeAgentProfileRequestDto,
|
BackgroundTaskDto, ChangeAgentProfileDto, ChangeAgentProfileRequestDto,
|
||||||
CloneOpenCodeProfileFromSeedRequestDto, ConfigureProfilesRequestDto, ConversationDetailsDto,
|
CloneOpenCodeProfileFromSeedRequestDto, ConfigureProfilesRequestDto, ConversationDetailsDto,
|
||||||
@ -45,19 +47,19 @@ use crate::dto::{
|
|||||||
GraphCommitListDto, HealthRequestDto, HealthResponseDto, InspectConversationRequestDto,
|
GraphCommitListDto, HealthRequestDto, HealthResponseDto, InspectConversationRequestDto,
|
||||||
InterruptAgentRequestDto, LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto,
|
InterruptAgentRequestDto, LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto,
|
||||||
LiveAgentListDto, MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto,
|
LiveAgentListDto, MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto,
|
||||||
ModelServerConfigDto, ModelServerConfigListDto, OpenTerminalRequestDto, ProfileDto,
|
ModelServerConfigDto, ModelServerConfigListDto, OpenTerminalRequestDto,
|
||||||
ProfileListDto, ProjectDto, ProjectListDto, ProjectPermissionsDto, ProjectWorkStateDto,
|
PreviewModelServerCommandDto, ProfileDto, ProfileListDto, ProjectDto, ProjectListDto,
|
||||||
ReadAgentContextResponseDto, ReadConversationPageRequestDto, ReattachChatDto,
|
ProjectPermissionsDto, ProjectWorkStateDto, ReadAgentContextResponseDto,
|
||||||
ReattachResultDto, RecallMemoryRequestDto, RenameLayoutRequestDto, ReplyChunk,
|
ReadConversationPageRequestDto, ReattachChatDto, ReattachResultDto, RecallMemoryRequestDto,
|
||||||
ResizeTerminalRequestDto, ResolveAgentPermissionsRequestDto, ResumableAgentListDto,
|
RenameLayoutRequestDto, ReplyChunk, ResizeTerminalRequestDto,
|
||||||
SaveEmbedderProfileRequestDto, SaveModelServerRequestDto, SaveProfileRequestDto,
|
ResolveAgentPermissionsRequestDto, ResumableAgentListDto, SaveEmbedderProfileRequestDto,
|
||||||
SetActiveLayoutRequestDto, SetActiveLayoutResultDto, SkillDto, SkillListDto,
|
SaveModelServerRequestDto, SaveProfileRequestDto, SetActiveLayoutRequestDto,
|
||||||
StopLiveAgentRequestDto, StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto,
|
SetActiveLayoutResultDto, SkillDto, SkillListDto, StopLiveAgentRequestDto,
|
||||||
SyncResultDto, TemplateDto, TemplateListDto, TerminalClosedDto, TerminalSessionDto,
|
StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto,
|
||||||
TurnPageDto, UnassignSkillRequestDto, UpdateAgentContextRequestDto,
|
TemplateListDto, TerminalClosedDto, TerminalSessionDto, TurnPageDto, UnassignSkillRequestDto,
|
||||||
UpdateAgentPermissionsRequestDto, UpdateMemoryRequestDto, UpdateProjectContextRequestDto,
|
UpdateAgentContextRequestDto, UpdateAgentPermissionsRequestDto, UpdateMemoryRequestDto,
|
||||||
UpdateProjectPermissionsRequestDto, UpdateSkillRequestDto, UpdateTemplateRequestDto,
|
UpdateProjectContextRequestDto, UpdateProjectPermissionsRequestDto, UpdateSkillRequestDto,
|
||||||
WriteTerminalRequestDto, parse_model_server_id, save_model_server_input,
|
UpdateTemplateRequestDto, WriteTerminalRequestDto,
|
||||||
};
|
};
|
||||||
use crate::pty::{PtyBridge, PtyChunk};
|
use crate::pty::{PtyBridge, PtyChunk};
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
@ -900,6 +902,28 @@ pub async fn save_model_server(
|
|||||||
.map_err(ErrorDto::from)
|
.map_err(ErrorDto::from)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `preview_model_server_command` — build the llama.cpp argv without persisting.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns an [`ErrorDto`] when the DTO or runtime invocation is invalid.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn preview_model_server_command(
|
||||||
|
config: ModelServerConfigDto,
|
||||||
|
) -> Result<PreviewModelServerCommandDto, ErrorDto> {
|
||||||
|
let config = model_server_config_domain(config, None)?;
|
||||||
|
let argv = infrastructure::LlamaCppRuntime::new()
|
||||||
|
.build_argv(&config)
|
||||||
|
.map_err(|err| ErrorDto {
|
||||||
|
code: "INVALID".to_owned(),
|
||||||
|
message: err.to_string(),
|
||||||
|
})?;
|
||||||
|
Ok(PreviewModelServerCommandDto {
|
||||||
|
display: display_command(&argv.command, &argv.args),
|
||||||
|
command: argv.command,
|
||||||
|
args: argv.args,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// `delete_model_server` — delete a local model server config when unused.
|
/// `delete_model_server` — delete a local model server config when unused.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
@ -924,6 +948,27 @@ fn model_server_command_error(err: AppError) -> ErrorDto {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn display_command(command: &str, args: &[String]) -> String {
|
||||||
|
std::iter::once(command)
|
||||||
|
.chain(args.iter().map(String::as_str))
|
||||||
|
.map(shell_escape)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shell_escape(value: &str) -> String {
|
||||||
|
if value.is_empty() {
|
||||||
|
return "''".to_owned();
|
||||||
|
}
|
||||||
|
if value
|
||||||
|
.chars()
|
||||||
|
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '/' | '.' | '_' | '-' | ':' | '='))
|
||||||
|
{
|
||||||
|
return value.to_owned();
|
||||||
|
}
|
||||||
|
format!("'{}'", value.replace('\'', "'\\''"))
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Embedder profiles & engines (LOT C2 — §14.5.3)
|
// Embedder profiles & engines (LOT C2 — §14.5.3)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@ -913,8 +913,8 @@ impl From<FirstRunStateOutput> for FirstRunStateDto {
|
|||||||
|
|
||||||
use application::{ListModelServersOutput, SaveModelServerInput, SaveModelServerOutput};
|
use application::{ListModelServersOutput, SaveModelServerInput, SaveModelServerOutput};
|
||||||
use domain::model_server::{
|
use domain::model_server::{
|
||||||
ExecutablePath, LocalModelRef, LocalModelServerConfig, LocalModelServerKind, ModelPath,
|
ExecutablePath, HfModelRef, LlamaCppOptions, LocalModelRef, LocalModelServerConfig,
|
||||||
ModelServerEndpoint,
|
LocalModelServerKind, ModelPath, ModelServerEndpoint, ModelSource,
|
||||||
};
|
};
|
||||||
use domain::{LocalModelServerId, StopPolicy};
|
use domain::{LocalModelServerId, StopPolicy};
|
||||||
|
|
||||||
@ -974,6 +974,44 @@ impl From<StopPolicyDto> for StopPolicy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Model source on the IPC wire.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", tag = "type")]
|
||||||
|
pub enum ModelSourceDto {
|
||||||
|
/// Local `.gguf` path.
|
||||||
|
LocalPath {
|
||||||
|
/// Absolute local path.
|
||||||
|
path: String,
|
||||||
|
},
|
||||||
|
/// Hugging Face `namespace/repo[:quant]` reference.
|
||||||
|
HuggingFace {
|
||||||
|
/// Repository reference.
|
||||||
|
repo: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ModelSource> for ModelSourceDto {
|
||||||
|
fn from(source: ModelSource) -> Self {
|
||||||
|
match source {
|
||||||
|
ModelSource::LocalPath { path } => Self::LocalPath { path: path.0 },
|
||||||
|
ModelSource::HuggingFace { repo } => Self::HuggingFace { repo: repo.0 },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ModelSourceDto {
|
||||||
|
fn into_domain(self) -> Result<ModelSource, ErrorDto> {
|
||||||
|
match self {
|
||||||
|
Self::LocalPath { path } => Ok(ModelSource::LocalPath {
|
||||||
|
path: ModelPath::new(path).map_err(invalid_domain_error)?,
|
||||||
|
}),
|
||||||
|
Self::HuggingFace { repo } => Ok(ModelSource::HuggingFace {
|
||||||
|
repo: HfModelRef::new(repo).map_err(invalid_domain_error)?,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A local model-server config crossing the wire.
|
/// A local model-server config crossing the wire.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@ -989,14 +1027,29 @@ pub struct ModelServerConfigDto {
|
|||||||
pub base_url: String,
|
pub base_url: String,
|
||||||
/// TCP port.
|
/// TCP port.
|
||||||
pub port: u16,
|
pub port: u16,
|
||||||
/// Optional explicit `.gguf` model path.
|
/// Optional explicit model source.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub model_source: Option<ModelSourceDto>,
|
||||||
|
/// Legacy V1 input alias for `modelSource:{type:"localPath",path}`.
|
||||||
|
#[serde(default, skip_serializing)]
|
||||||
pub model_path: Option<String>,
|
pub model_path: Option<String>,
|
||||||
/// Served model name exposed to OpenCode.
|
/// Served model name exposed to OpenCode.
|
||||||
pub served_model_name: String,
|
pub served_model_name: String,
|
||||||
/// Optional explicit `llama-server` executable path.
|
/// Optional explicit `llama-server` executable path.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub binary_path: Option<String>,
|
pub binary_path: Option<String>,
|
||||||
|
/// llama.cpp host passed to `--host`.
|
||||||
|
#[serde(default = "default_llamacpp_host")]
|
||||||
|
pub host: String,
|
||||||
|
/// llama.cpp GPU layers passed to `-ngl`.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub gpu_layers: Option<u32>,
|
||||||
|
/// llama.cpp context size passed to `-c`.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub context_size: Option<u32>,
|
||||||
|
/// Whether to pass `--jinja`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub jinja: bool,
|
||||||
/// Extra argv entries.
|
/// Extra argv entries.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub args: Vec<String>,
|
pub args: Vec<String>,
|
||||||
@ -1016,12 +1069,14 @@ impl ModelServerConfigDto {
|
|||||||
name: config.name,
|
name: config.name,
|
||||||
base_url: config.endpoint.base_url,
|
base_url: config.endpoint.base_url,
|
||||||
port: config.endpoint.port,
|
port: config.endpoint.port,
|
||||||
model_path: config
|
model_source: config.model.source.map(Into::into),
|
||||||
.model
|
model_path: None,
|
||||||
.path
|
|
||||||
.map(|path| path.as_str().to_owned()),
|
|
||||||
served_model_name: config.model.served_name,
|
served_model_name: config.model.served_name,
|
||||||
binary_path: config.binary.map(|binary| binary.as_str().to_owned()),
|
binary_path: config.binary.map(|binary| binary.as_str().to_owned()),
|
||||||
|
host: config.options.host,
|
||||||
|
gpu_layers: config.options.gpu_layers,
|
||||||
|
context_size: config.options.context_size,
|
||||||
|
jinja: config.options.jinja,
|
||||||
args: config.args,
|
args: config.args,
|
||||||
auto_start: config.auto_start,
|
auto_start: config.auto_start,
|
||||||
stop_policy: config.stop_policy.into(),
|
stop_policy: config.stop_policy.into(),
|
||||||
@ -1040,26 +1095,21 @@ impl ModelServerConfigDto {
|
|||||||
let server_id = parse_model_server_id(&self.id)?;
|
let server_id = parse_model_server_id(&self.id)?;
|
||||||
let endpoint = ModelServerEndpoint::new(self.base_url.clone(), self.port)
|
let endpoint = ModelServerEndpoint::new(self.base_url.clone(), self.port)
|
||||||
.map_err(invalid_domain_error)?;
|
.map_err(invalid_domain_error)?;
|
||||||
let model_path = optional_non_empty(self.model_path)
|
let source = resolve_model_source(self.model_source, self.model_path)?;
|
||||||
.map(ModelPath::new)
|
|
||||||
.transpose()
|
|
||||||
.map_err(invalid_domain_error)?;
|
|
||||||
let model_id = existing
|
let model_id = existing
|
||||||
.filter(|config| config.id == server_id)
|
.filter(|config| config.id == server_id)
|
||||||
.map(|config| config.model.id.clone())
|
.map(|config| config.model.id.clone())
|
||||||
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
|
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
|
||||||
let label = non_empty_or_fallback(&self.served_model_name, &self.name);
|
let label = non_empty_or_fallback(&self.served_model_name, &self.name);
|
||||||
let model = LocalModelRef::new(
|
let model = LocalModelRef::new(model_id, label, source, self.served_model_name.clone())
|
||||||
model_id,
|
|
||||||
label,
|
|
||||||
model_path,
|
|
||||||
self.served_model_name.clone(),
|
|
||||||
)
|
|
||||||
.map_err(invalid_domain_error)?;
|
.map_err(invalid_domain_error)?;
|
||||||
let binary = optional_non_empty(self.binary_path)
|
let binary = optional_non_empty(self.binary_path)
|
||||||
.map(ExecutablePath::new)
|
.map(ExecutablePath::new)
|
||||||
.transpose()
|
.transpose()
|
||||||
.map_err(invalid_domain_error)?;
|
.map_err(invalid_domain_error)?;
|
||||||
|
let options =
|
||||||
|
LlamaCppOptions::new(self.host, self.gpu_layers, self.context_size, self.jinja)
|
||||||
|
.map_err(invalid_domain_error)?;
|
||||||
LocalModelServerConfig::new(
|
LocalModelServerConfig::new(
|
||||||
server_id,
|
server_id,
|
||||||
self.kind.into(),
|
self.kind.into(),
|
||||||
@ -1067,6 +1117,7 @@ impl ModelServerConfigDto {
|
|||||||
endpoint,
|
endpoint,
|
||||||
model,
|
model,
|
||||||
binary,
|
binary,
|
||||||
|
options,
|
||||||
self.args,
|
self.args,
|
||||||
self.auto_start,
|
self.auto_start,
|
||||||
self.stop_policy.into(),
|
self.stop_policy.into(),
|
||||||
@ -1075,6 +1126,18 @@ impl ModelServerConfigDto {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Response DTO for `preview_model_server_command`.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PreviewModelServerCommandDto {
|
||||||
|
/// Executable command.
|
||||||
|
pub command: String,
|
||||||
|
/// Arguments without shell parsing.
|
||||||
|
pub args: Vec<String>,
|
||||||
|
/// Human-readable escaped command line.
|
||||||
|
pub display: String,
|
||||||
|
}
|
||||||
|
|
||||||
/// List response for local model servers.
|
/// List response for local model servers.
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
#[serde(transparent)]
|
#[serde(transparent)]
|
||||||
@ -1132,11 +1195,52 @@ pub fn save_model_server_input(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Converts a model-server DTO to domain using the same path as save.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`ErrorDto`] if DTO-to-domain validation fails.
|
||||||
|
pub fn model_server_config_domain(
|
||||||
|
config: ModelServerConfigDto,
|
||||||
|
existing: Option<&LocalModelServerConfig>,
|
||||||
|
) -> Result<LocalModelServerConfig, ErrorDto> {
|
||||||
|
config.into_domain(existing)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_model_source(
|
||||||
|
model_source: Option<ModelSourceDto>,
|
||||||
|
model_path: Option<String>,
|
||||||
|
) -> Result<Option<ModelSource>, ErrorDto> {
|
||||||
|
let legacy_path = optional_non_empty(model_path);
|
||||||
|
match (model_source, legacy_path) {
|
||||||
|
(None, None) => Ok(None),
|
||||||
|
(None, Some(path)) => Ok(Some(ModelSource::LocalPath {
|
||||||
|
path: ModelPath::new(path).map_err(invalid_domain_error)?,
|
||||||
|
})),
|
||||||
|
(Some(source), None) => Ok(Some(source.into_domain()?)),
|
||||||
|
(Some(source), Some(path)) => {
|
||||||
|
let domain_source = source.into_domain()?;
|
||||||
|
match &domain_source {
|
||||||
|
ModelSource::LocalPath { path: source_path } if source_path.as_str() == path => {
|
||||||
|
Ok(Some(domain_source))
|
||||||
|
}
|
||||||
|
_ => Err(ErrorDto {
|
||||||
|
code: "INVALID".to_owned(),
|
||||||
|
message: "modelPath and modelSource differ".to_owned(),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn optional_non_empty(raw: Option<String>) -> Option<String> {
|
fn optional_non_empty(raw: Option<String>) -> Option<String> {
|
||||||
raw.map(|value| value.trim().to_owned())
|
raw.map(|value| value.trim().to_owned())
|
||||||
.filter(|value| !value.is_empty())
|
.filter(|value| !value.is_empty())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_llamacpp_host() -> String {
|
||||||
|
"127.0.0.1".to_owned()
|
||||||
|
}
|
||||||
|
|
||||||
fn non_empty_or_fallback(primary: &str, fallback: &str) -> String {
|
fn non_empty_or_fallback(primary: &str, fallback: &str) -> String {
|
||||||
let primary = primary.trim();
|
let primary = primary.trim();
|
||||||
if primary.is_empty() {
|
if primary.is_empty() {
|
||||||
|
|||||||
@ -164,6 +164,7 @@ pub fn run() {
|
|||||||
commands::configure_profiles,
|
commands::configure_profiles,
|
||||||
commands::list_model_servers,
|
commands::list_model_servers,
|
||||||
commands::save_model_server,
|
commands::save_model_server,
|
||||||
|
commands::preview_model_server_command,
|
||||||
commands::delete_model_server,
|
commands::delete_model_server,
|
||||||
commands::list_embedder_profiles,
|
commands::list_embedder_profiles,
|
||||||
commands::save_embedder_profile,
|
commands::save_embedder_profile,
|
||||||
|
|||||||
@ -1,12 +1,12 @@
|
|||||||
//! DTO contract tests for local model-server IPC.
|
//! DTO contract tests for local model-server IPC.
|
||||||
|
|
||||||
use app_tauri_lib::dto::{
|
use app_tauri_lib::dto::{
|
||||||
ModelServerConfigDto, ModelServerKindDto, SaveModelServerRequestDto, StopPolicyDto,
|
save_model_server_input, ModelServerConfigDto, ModelServerKindDto, ModelSourceDto,
|
||||||
save_model_server_input,
|
SaveModelServerRequestDto, StopPolicyDto,
|
||||||
};
|
};
|
||||||
use domain::model_server::{
|
use domain::model_server::{
|
||||||
ExecutablePath, LocalModelRef, LocalModelServerConfig, LocalModelServerKind, ModelPath,
|
ExecutablePath, LlamaCppOptions, LocalModelRef, LocalModelServerConfig, LocalModelServerKind,
|
||||||
ModelServerEndpoint,
|
ModelPath, ModelServerEndpoint, ModelSource,
|
||||||
};
|
};
|
||||||
use domain::{LocalModelServerId, StopPolicy};
|
use domain::{LocalModelServerId, StopPolicy};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
@ -25,12 +25,15 @@ fn domain_config(id: LocalModelServerId, model_id: &str) -> LocalModelServerConf
|
|||||||
LocalModelRef::new(
|
LocalModelRef::new(
|
||||||
model_id,
|
model_id,
|
||||||
"Qwen",
|
"Qwen",
|
||||||
Some(ModelPath::new("/models/qwen.gguf").unwrap()),
|
Some(ModelSource::LocalPath {
|
||||||
|
path: ModelPath::new("/models/qwen.gguf").unwrap(),
|
||||||
|
}),
|
||||||
"qwen3-coder",
|
"qwen3-coder",
|
||||||
)
|
)
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
Some(ExecutablePath::new("/usr/bin/llama-server").unwrap()),
|
Some(ExecutablePath::new("/usr/bin/llama-server").unwrap()),
|
||||||
vec!["--ctx-size".to_owned(), "4096".to_owned()],
|
LlamaCppOptions::new("127.0.0.1", Some(35), Some(4096), true).unwrap(),
|
||||||
|
vec!["--threads".to_owned(), "8".to_owned()],
|
||||||
true,
|
true,
|
||||||
StopPolicy::StopOnAppExit,
|
StopPolicy::StopOnAppExit,
|
||||||
)
|
)
|
||||||
@ -46,12 +49,21 @@ fn model_server_dto_serialises_flat_camelcase_wire_shape() {
|
|||||||
assert_eq!(value["id"], sid(35).to_string());
|
assert_eq!(value["id"], sid(35).to_string());
|
||||||
assert_eq!(value["kind"], "llamaCpp");
|
assert_eq!(value["kind"], "llamaCpp");
|
||||||
assert_eq!(value["baseURL"], "http://localhost:8080/v1");
|
assert_eq!(value["baseURL"], "http://localhost:8080/v1");
|
||||||
assert_eq!(value["modelPath"], "/models/qwen.gguf");
|
assert_eq!(value["modelSource"]["type"], "localPath");
|
||||||
|
assert_eq!(value["modelSource"]["path"], "/models/qwen.gguf");
|
||||||
|
assert!(value.get("modelPath").is_none());
|
||||||
assert_eq!(value["servedModelName"], "qwen3-coder");
|
assert_eq!(value["servedModelName"], "qwen3-coder");
|
||||||
assert_eq!(value["binaryPath"], "/usr/bin/llama-server");
|
assert_eq!(value["binaryPath"], "/usr/bin/llama-server");
|
||||||
|
assert_eq!(value["host"], "127.0.0.1");
|
||||||
|
assert_eq!(value["gpuLayers"], 35);
|
||||||
|
assert_eq!(value["contextSize"], 4096);
|
||||||
|
assert_eq!(value["jinja"], true);
|
||||||
assert_eq!(value["stopPolicy"], "stopOnAppExit");
|
assert_eq!(value["stopPolicy"], "stopOnAppExit");
|
||||||
assert!(value.get("model").is_none(), "domain model must not leak");
|
assert!(value.get("model").is_none(), "domain model must not leak");
|
||||||
assert!(value.get("endpoint").is_none(), "domain endpoint must not leak");
|
assert!(
|
||||||
|
value.get("endpoint").is_none(),
|
||||||
|
"domain endpoint must not leak"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -65,7 +77,9 @@ fn model_server_dto_deserialises_flat_wire_shape_and_generates_model_id() {
|
|||||||
"modelPath": "/models/qwen.gguf",
|
"modelPath": "/models/qwen.gguf",
|
||||||
"servedModelName": "qwen3-coder",
|
"servedModelName": "qwen3-coder",
|
||||||
"binaryPath": "/usr/bin/llama-server",
|
"binaryPath": "/usr/bin/llama-server",
|
||||||
"args": ["--ctx-size", "4096"],
|
"gpuLayers": 35,
|
||||||
|
"contextSize": 4096,
|
||||||
|
"args": ["--threads", "8"],
|
||||||
"autoStart": true,
|
"autoStart": true,
|
||||||
"stopPolicy": "stopOnAppExit"
|
"stopPolicy": "stopOnAppExit"
|
||||||
});
|
});
|
||||||
@ -79,9 +93,15 @@ fn model_server_dto_deserialises_flat_wire_shape_and_generates_model_id() {
|
|||||||
assert_eq!(input.config.kind, LocalModelServerKind::LlamaCpp);
|
assert_eq!(input.config.kind, LocalModelServerKind::LlamaCpp);
|
||||||
assert_eq!(input.config.endpoint.base_url, "http://localhost:8081/v1");
|
assert_eq!(input.config.endpoint.base_url, "http://localhost:8081/v1");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
input.config.model.path.unwrap().as_str(),
|
input.config.model.source,
|
||||||
"/models/qwen.gguf"
|
Some(ModelSource::LocalPath {
|
||||||
|
path: ModelPath::new("/models/qwen.gguf").unwrap()
|
||||||
|
})
|
||||||
);
|
);
|
||||||
|
assert_eq!(input.config.options.gpu_layers, Some(35));
|
||||||
|
assert_eq!(input.config.options.context_size, Some(4096));
|
||||||
|
assert_eq!(input.config.options.host, "127.0.0.1");
|
||||||
|
assert!(!input.config.options.jinja);
|
||||||
assert_eq!(input.config.model.served_name, "qwen3-coder");
|
assert_eq!(input.config.model.served_name, "qwen3-coder");
|
||||||
assert_eq!(input.config.model.label, "qwen3-coder");
|
assert_eq!(input.config.model.label, "qwen3-coder");
|
||||||
assert!(!input.config.model.id.is_empty());
|
assert!(!input.config.model.id.is_empty());
|
||||||
@ -96,9 +116,16 @@ fn model_server_dto_preserves_existing_internal_model_id_on_upsert() {
|
|||||||
name: "Updated Qwen".to_owned(),
|
name: "Updated Qwen".to_owned(),
|
||||||
base_url: "http://localhost:8082/v1".to_owned(),
|
base_url: "http://localhost:8082/v1".to_owned(),
|
||||||
port: 8082,
|
port: 8082,
|
||||||
model_path: Some("/models/qwen-updated.gguf".to_owned()),
|
model_source: Some(ModelSourceDto::LocalPath {
|
||||||
|
path: "/models/qwen-updated.gguf".to_owned(),
|
||||||
|
}),
|
||||||
|
model_path: None,
|
||||||
served_model_name: "qwen3-coder-updated".to_owned(),
|
served_model_name: "qwen3-coder-updated".to_owned(),
|
||||||
binary_path: Some("/usr/bin/llama-server".to_owned()),
|
binary_path: Some("/usr/bin/llama-server".to_owned()),
|
||||||
|
host: "127.0.0.1".to_owned(),
|
||||||
|
gpu_layers: None,
|
||||||
|
context_size: None,
|
||||||
|
jinja: false,
|
||||||
args: Vec::new(),
|
args: Vec::new(),
|
||||||
auto_start: true,
|
auto_start: true,
|
||||||
stop_policy: StopPolicyDto::StopOnAppExit,
|
stop_policy: StopPolicyDto::StopOnAppExit,
|
||||||
@ -109,3 +136,66 @@ fn model_server_dto_preserves_existing_internal_model_id_on_upsert() {
|
|||||||
assert_eq!(config.model.id, "stable-internal-id");
|
assert_eq!(config.model.id, "stable-internal-id");
|
||||||
assert_eq!(config.model.served_name, "qwen3-coder-updated");
|
assert_eq!(config.model.served_name, "qwen3-coder-updated");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn model_server_dto_roundtrips_v2_huggingface_source() {
|
||||||
|
let raw = json!({
|
||||||
|
"id": sid(38).to_string(),
|
||||||
|
"kind": "llamaCpp",
|
||||||
|
"name": "HF Qwen",
|
||||||
|
"baseURL": "http://localhost:8083",
|
||||||
|
"port": 8083,
|
||||||
|
"modelSource": { "type": "huggingFace", "repo": "Qwen/Qwen3-Coder:Q4_K_M" },
|
||||||
|
"servedModelName": "qwen3-coder",
|
||||||
|
"binaryPath": "/usr/bin/llama-server",
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
"jinja": false,
|
||||||
|
"args": [],
|
||||||
|
"autoStart": true,
|
||||||
|
"stopPolicy": "stopOnAppExit"
|
||||||
|
});
|
||||||
|
|
||||||
|
let request = SaveModelServerRequestDto {
|
||||||
|
config: serde_json::from_value(raw).unwrap(),
|
||||||
|
};
|
||||||
|
let input = save_model_server_input(request, None).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
input.config.model.source,
|
||||||
|
Some(ModelSource::HuggingFace {
|
||||||
|
repo: domain::model_server::HfModelRef::new("Qwen/Qwen3-Coder:Q4_K_M").unwrap()
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
let value = serde_json::to_value(ModelServerConfigDto::from_domain(input.config)).unwrap();
|
||||||
|
assert_eq!(value["modelSource"]["type"], "huggingFace");
|
||||||
|
assert_eq!(value["modelSource"]["repo"], "Qwen/Qwen3-Coder:Q4_K_M");
|
||||||
|
assert!(value.get("modelPath").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn model_server_dto_rejects_conflicting_model_path_alias() {
|
||||||
|
let raw = json!({
|
||||||
|
"id": sid(39).to_string(),
|
||||||
|
"kind": "llamaCpp",
|
||||||
|
"name": "Local Qwen",
|
||||||
|
"baseURL": "http://localhost:8084",
|
||||||
|
"port": 8084,
|
||||||
|
"modelSource": { "type": "localPath", "path": "/models/a.gguf" },
|
||||||
|
"modelPath": "/models/b.gguf",
|
||||||
|
"servedModelName": "qwen3-coder",
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
"jinja": false,
|
||||||
|
"args": [],
|
||||||
|
"autoStart": true,
|
||||||
|
"stopPolicy": "stopOnAppExit"
|
||||||
|
});
|
||||||
|
|
||||||
|
let request = SaveModelServerRequestDto {
|
||||||
|
config: serde_json::from_value(raw).unwrap(),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
save_model_server_input(request, None).unwrap_err().code,
|
||||||
|
"INVALID"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -7,6 +7,7 @@ use std::time::Duration;
|
|||||||
use domain::events::DomainEvent;
|
use domain::events::DomainEvent;
|
||||||
use domain::model_server::{
|
use domain::model_server::{
|
||||||
LocalModelServerConfig, ModelServerLifecycleStatus, ModelServerReady, ModelServerStatus,
|
LocalModelServerConfig, ModelServerLifecycleStatus, ModelServerReady, ModelServerStatus,
|
||||||
|
ModelSource,
|
||||||
};
|
};
|
||||||
use domain::ports::{
|
use domain::ports::{
|
||||||
EventBus, FileSystem, ManagedProcess, ManagedProcessHandle, ModelServerError, ModelServerProbe,
|
EventBus, FileSystem, ManagedProcess, ManagedProcessHandle, ModelServerError, ModelServerProbe,
|
||||||
@ -332,11 +333,14 @@ impl EnsureLocalModelServer {
|
|||||||
&self,
|
&self,
|
||||||
config: &LocalModelServerConfig,
|
config: &LocalModelServerConfig,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
let Some(path) = config.model.path.as_ref() else {
|
let Some(ModelSource::LocalPath { path }) = config.model.source.as_ref() else {
|
||||||
let err = ModelServerError::PathNotAccessible("model.path missing".to_owned());
|
return Ok(());
|
||||||
|
};
|
||||||
|
if path.as_str().is_empty() {
|
||||||
|
let err = ModelServerError::PathNotAccessible("model.source.path missing".to_owned());
|
||||||
self.publish_failure(config.id, &err);
|
self.publish_failure(config.id, &err);
|
||||||
return Err(err.into());
|
return Err(err.into());
|
||||||
};
|
}
|
||||||
match self
|
match self
|
||||||
.fs
|
.fs
|
||||||
.exists(&RemotePath::new(path.as_str().to_owned()))
|
.exists(&RemotePath::new(path.as_str().to_owned()))
|
||||||
|
|||||||
@ -12,13 +12,13 @@ use application::{
|
|||||||
};
|
};
|
||||||
use domain::events::DomainEvent;
|
use domain::events::DomainEvent;
|
||||||
use domain::model_server::{
|
use domain::model_server::{
|
||||||
ExecutablePath, LocalModelRef, LocalModelServerConfig, LocalModelServerKind, ModelPath,
|
ExecutablePath, LlamaCppOptions, LocalModelRef, LocalModelServerConfig, LocalModelServerKind,
|
||||||
ModelServerEndpoint, ModelServerStatus, StopPolicy,
|
ModelPath, ModelServerEndpoint, ModelServerStatus, ModelSource, StopPolicy,
|
||||||
};
|
};
|
||||||
use domain::ports::{
|
use domain::ports::{
|
||||||
DirEntry, EventBus, EventStream, FileSystem, FsError, ManagedProcess, ManagedProcessHandle,
|
DirEntry, EventBus, EventStream, FileSystem, FsError, ManagedProcess, ManagedProcessHandle,
|
||||||
ModelServerError, ModelServerProbe, ModelServerRegistry, ModelServerRuntime, ProcessStatus,
|
ModelServerArgv, ModelServerError, ModelServerProbe, ModelServerRegistry, ModelServerRuntime,
|
||||||
ProfileStore, RemotePath, SpawnSpec, StoreError,
|
ProcessStatus, ProfileStore, RemotePath, SpawnSpec, StoreError,
|
||||||
};
|
};
|
||||||
use domain::profile::{AgentProfile, ContextInjection, OpenCodeConfig, StructuredAdapter};
|
use domain::profile::{AgentProfile, ContextInjection, OpenCodeConfig, StructuredAdapter};
|
||||||
use domain::{LocalModelServerId, ProfileId, ProjectPath};
|
use domain::{LocalModelServerId, ProfileId, ProjectPath};
|
||||||
@ -41,11 +41,14 @@ fn config(
|
|||||||
LocalModelRef::new(
|
LocalModelRef::new(
|
||||||
"qwen",
|
"qwen",
|
||||||
"Qwen",
|
"Qwen",
|
||||||
Some(ModelPath::new(path).unwrap()),
|
Some(ModelSource::LocalPath {
|
||||||
|
path: ModelPath::new(path).unwrap(),
|
||||||
|
}),
|
||||||
"qwen3-coder-30b",
|
"qwen3-coder-30b",
|
||||||
)
|
)
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
Some(ExecutablePath::new("llama-server").unwrap()),
|
Some(ExecutablePath::new("llama-server").unwrap()),
|
||||||
|
LlamaCppOptions::default(),
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
auto_start,
|
auto_start,
|
||||||
StopPolicy::StopOnAppExit,
|
StopPolicy::StopOnAppExit,
|
||||||
@ -192,11 +195,16 @@ impl ManagedProcess for FakeProcess {
|
|||||||
struct FakeRuntime;
|
struct FakeRuntime;
|
||||||
|
|
||||||
impl ModelServerRuntime for FakeRuntime {
|
impl ModelServerRuntime for FakeRuntime {
|
||||||
fn build_spawn_spec(
|
fn build_argv(
|
||||||
&self,
|
&self,
|
||||||
config: &LocalModelServerConfig,
|
config: &LocalModelServerConfig,
|
||||||
) -> Result<SpawnSpec, ModelServerError> {
|
) -> Result<ModelServerArgv, ModelServerError> {
|
||||||
Ok(SpawnSpec {
|
let Some(ModelSource::LocalPath { path }) = config.model.source.as_ref() else {
|
||||||
|
return Err(ModelServerError::PathNotAccessible(
|
||||||
|
"model.source missing".to_owned(),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
Ok(ModelServerArgv {
|
||||||
command: config
|
command: config
|
||||||
.binary
|
.binary
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@ -204,10 +212,23 @@ impl ModelServerRuntime for FakeRuntime {
|
|||||||
.unwrap_or_else(|| "llama-server".to_owned()),
|
.unwrap_or_else(|| "llama-server".to_owned()),
|
||||||
args: vec![
|
args: vec![
|
||||||
"--model".to_owned(),
|
"--model".to_owned(),
|
||||||
config.model.path.as_ref().unwrap().as_str().to_owned(),
|
path.as_str().to_owned(),
|
||||||
"--port".to_owned(),
|
"--port".to_owned(),
|
||||||
config.endpoint.port.to_string(),
|
config.endpoint.port.to_string(),
|
||||||
|
"--host".to_owned(),
|
||||||
|
config.options.host.clone(),
|
||||||
],
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_spawn_spec(
|
||||||
|
&self,
|
||||||
|
config: &LocalModelServerConfig,
|
||||||
|
) -> Result<SpawnSpec, ModelServerError> {
|
||||||
|
let argv = self.build_argv(config)?;
|
||||||
|
Ok(SpawnSpec {
|
||||||
|
command: argv.command,
|
||||||
|
args: argv.args,
|
||||||
cwd: ProjectPath::new("/").unwrap(),
|
cwd: ProjectPath::new("/").unwrap(),
|
||||||
env: Vec::new(),
|
env: Vec::new(),
|
||||||
context_plan: None,
|
context_plan: None,
|
||||||
@ -341,7 +362,14 @@ async fn absent_auto_start_spawns_and_waits_until_ready() {
|
|||||||
assert_eq!(spawns[0].command, "llama-server");
|
assert_eq!(spawns[0].command, "llama-server");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
spawns[0].args,
|
spawns[0].args,
|
||||||
vec!["--model", "/models/qwen.gguf", "--port", "8081"]
|
vec![
|
||||||
|
"--model",
|
||||||
|
"/models/qwen.gguf",
|
||||||
|
"--port",
|
||||||
|
"8081",
|
||||||
|
"--host",
|
||||||
|
"127.0.0.1"
|
||||||
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -155,9 +155,9 @@ pub use memory_harvest::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
pub use model_server::{
|
pub use model_server::{
|
||||||
ExecutablePath, LocalModelRef, LocalModelServerConfig, LocalModelServerKind, ModelPath,
|
validate_free_args, ExecutablePath, HfModelRef, LlamaCppOptions, LocalModelRef,
|
||||||
ModelServerEndpoint, ModelServerLifecycleStatus, ModelServerReady, ModelServerStatus,
|
LocalModelServerConfig, LocalModelServerKind, ModelPath, ModelServerEndpoint,
|
||||||
StopPolicy,
|
ModelServerLifecycleStatus, ModelServerReady, ModelServerStatus, ModelSource, StopPolicy,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub use remote::{RemoteKind, RemoteRef, SshAuth};
|
pub use remote::{RemoteKind, RemoteRef, SshAuth};
|
||||||
|
|||||||
@ -116,6 +116,81 @@ impl ModelPath {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Explicit source of the served model.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", tag = "type")]
|
||||||
|
pub enum ModelSource {
|
||||||
|
/// Local `.gguf` file path.
|
||||||
|
LocalPath {
|
||||||
|
/// Absolute local path.
|
||||||
|
path: ModelPath,
|
||||||
|
},
|
||||||
|
/// Hugging Face llama.cpp `-hf` reference.
|
||||||
|
HuggingFace {
|
||||||
|
/// `namespace/repo` or `namespace/repo:quant`.
|
||||||
|
repo: HfModelRef,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hugging Face model reference accepted by `llama-server -hf`.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(transparent)]
|
||||||
|
pub struct HfModelRef(pub String);
|
||||||
|
|
||||||
|
impl HfModelRef {
|
||||||
|
/// Builds a validated Hugging Face model reference.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns [`DomainError`] if the reference is blank, URL-like, contains
|
||||||
|
/// spaces, or does not match `namespace/repo[:quant]`.
|
||||||
|
pub fn new(repo: impl Into<String>) -> Result<Self, DomainError> {
|
||||||
|
let repo = repo.into();
|
||||||
|
crate::validation::non_empty(&repo, "modelServer.model.source.repo")?;
|
||||||
|
if repo.chars().any(char::is_whitespace) {
|
||||||
|
return Err(DomainError::Invariant(
|
||||||
|
"modelServer.model.source.repo must not contain spaces".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if repo.starts_with("http://") || repo.starts_with("https://") {
|
||||||
|
return Err(DomainError::Invariant(
|
||||||
|
"modelServer.model.source.repo must not be an URL in V1".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if repo.matches(':').count() > 1 {
|
||||||
|
return Err(DomainError::Invariant(
|
||||||
|
"modelServer.model.source.repo must contain at most one ':'".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let (base, quant) = repo
|
||||||
|
.split_once(':')
|
||||||
|
.map_or((repo.as_str(), None), |(base, quant)| (base, Some(quant)));
|
||||||
|
let Some((namespace, name)) = base.split_once('/') else {
|
||||||
|
return Err(DomainError::Invariant(
|
||||||
|
"modelServer.model.source.repo must be namespace/repo or namespace/repo:quant"
|
||||||
|
.to_owned(),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
if namespace.is_empty() || name.is_empty() || base.matches('/').count() != 1 {
|
||||||
|
return Err(DomainError::Invariant(
|
||||||
|
"modelServer.model.source.repo must be namespace/repo or namespace/repo:quant"
|
||||||
|
.to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if quant.is_some_and(str::is_empty) {
|
||||||
|
return Err(DomainError::Invariant(
|
||||||
|
"modelServer.model.source.repo quant must not be empty".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(Self(repo))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the reference as a string slice.
|
||||||
|
#[must_use]
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Absolute local path to an executable.
|
/// Absolute local path to an executable.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(transparent)]
|
#[serde(transparent)]
|
||||||
@ -159,9 +234,9 @@ pub struct LocalModelRef {
|
|||||||
pub id: String,
|
pub id: String,
|
||||||
/// Human display label.
|
/// Human display label.
|
||||||
pub label: String,
|
pub label: String,
|
||||||
/// Explicit absolute `.gguf` path. Required for auto-start.
|
/// Explicit source. Required for auto-start.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub path: Option<ModelPath>,
|
pub source: Option<ModelSource>,
|
||||||
/// Name exposed by the server and used in `OpenCodeConfig.model`.
|
/// Name exposed by the server and used in `OpenCodeConfig.model`.
|
||||||
pub served_name: String,
|
pub served_name: String,
|
||||||
}
|
}
|
||||||
@ -174,7 +249,7 @@ impl LocalModelRef {
|
|||||||
pub fn new(
|
pub fn new(
|
||||||
id: impl Into<String>,
|
id: impl Into<String>,
|
||||||
label: impl Into<String>,
|
label: impl Into<String>,
|
||||||
path: Option<ModelPath>,
|
source: Option<ModelSource>,
|
||||||
served_name: impl Into<String>,
|
served_name: impl Into<String>,
|
||||||
) -> Result<Self, DomainError> {
|
) -> Result<Self, DomainError> {
|
||||||
let id = id.into();
|
let id = id.into();
|
||||||
@ -186,12 +261,121 @@ impl LocalModelRef {
|
|||||||
Ok(Self {
|
Ok(Self {
|
||||||
id,
|
id,
|
||||||
label,
|
label,
|
||||||
path,
|
source,
|
||||||
served_name,
|
served_name,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Structured llama.cpp server options owned by IdeA.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct LlamaCppOptions {
|
||||||
|
/// Host passed to `--host`.
|
||||||
|
pub host: String,
|
||||||
|
/// GPU layers passed to `-ngl`.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub gpu_layers: Option<u32>,
|
||||||
|
/// Context size passed to `-c`.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub context_size: Option<u32>,
|
||||||
|
/// Enables llama.cpp Jinja templates.
|
||||||
|
pub jinja: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for LlamaCppOptions {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
host: "127.0.0.1".to_owned(),
|
||||||
|
gpu_layers: None,
|
||||||
|
context_size: None,
|
||||||
|
jinja: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LlamaCppOptions {
|
||||||
|
/// Builds validated llama.cpp options.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns [`DomainError`] if host is blank/contains spaces or numeric
|
||||||
|
/// options are zero.
|
||||||
|
pub fn new(
|
||||||
|
host: impl Into<String>,
|
||||||
|
gpu_layers: Option<u32>,
|
||||||
|
context_size: Option<u32>,
|
||||||
|
jinja: bool,
|
||||||
|
) -> Result<Self, DomainError> {
|
||||||
|
let options = Self {
|
||||||
|
host: host.into(),
|
||||||
|
gpu_layers,
|
||||||
|
context_size,
|
||||||
|
jinja,
|
||||||
|
};
|
||||||
|
options.validate()?;
|
||||||
|
Ok(options)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates option invariants.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns [`DomainError`] on invalid options.
|
||||||
|
pub fn validate(&self) -> Result<(), DomainError> {
|
||||||
|
crate::validation::non_empty(&self.host, "modelServer.options.host")?;
|
||||||
|
if self.host.chars().any(char::is_whitespace) {
|
||||||
|
return Err(DomainError::Invariant(
|
||||||
|
"modelServer.options.host must not contain spaces".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if self.gpu_layers == Some(0) {
|
||||||
|
return Err(DomainError::Invariant(
|
||||||
|
"modelServer.options.gpuLayers must be > 0".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if self.context_size == Some(0) {
|
||||||
|
return Err(DomainError::Invariant(
|
||||||
|
"modelServer.options.contextSize must be > 0".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates free-form runtime arguments do not duplicate structured options.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns [`DomainError`] if an argument is a reserved exact flag or
|
||||||
|
/// `--flag=value` form.
|
||||||
|
pub fn validate_free_args(args: &[String]) -> Result<(), DomainError> {
|
||||||
|
const RESERVED: &[&str] = &[
|
||||||
|
"--model",
|
||||||
|
"-m",
|
||||||
|
"-hf",
|
||||||
|
"--port",
|
||||||
|
"--host",
|
||||||
|
"-ngl",
|
||||||
|
"--gpu-layers",
|
||||||
|
"-c",
|
||||||
|
"--ctx-size",
|
||||||
|
"--jinja",
|
||||||
|
];
|
||||||
|
for arg in args {
|
||||||
|
if RESERVED.contains(&arg.as_str()) {
|
||||||
|
return Err(DomainError::Invariant(format!(
|
||||||
|
"modelServer.args contains reserved flag {arg}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if let Some((flag, _)) = arg.split_once('=') {
|
||||||
|
if RESERVED.contains(&flag) {
|
||||||
|
return Err(DomainError::Invariant(format!(
|
||||||
|
"modelServer.args contains reserved flag {flag}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Stop policy for a managed local model server.
|
/// Stop policy for a managed local model server.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@ -221,6 +405,8 @@ pub struct LocalModelServerConfig {
|
|||||||
/// Optional executable path or bare command name.
|
/// Optional executable path or bare command name.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub binary: Option<ExecutablePath>,
|
pub binary: Option<ExecutablePath>,
|
||||||
|
/// Structured llama.cpp options.
|
||||||
|
pub options: LlamaCppOptions,
|
||||||
/// Extra argv passed to the runtime.
|
/// Extra argv passed to the runtime.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub args: Vec<String>,
|
pub args: Vec<String>,
|
||||||
@ -249,21 +435,24 @@ impl LocalModelServerConfig {
|
|||||||
endpoint: ModelServerEndpoint,
|
endpoint: ModelServerEndpoint,
|
||||||
model: LocalModelRef,
|
model: LocalModelRef,
|
||||||
binary: Option<ExecutablePath>,
|
binary: Option<ExecutablePath>,
|
||||||
|
options: LlamaCppOptions,
|
||||||
args: Vec<String>,
|
args: Vec<String>,
|
||||||
auto_start: bool,
|
auto_start: bool,
|
||||||
stop_policy: StopPolicy,
|
stop_policy: StopPolicy,
|
||||||
) -> Result<Self, DomainError> {
|
) -> Result<Self, DomainError> {
|
||||||
let name = name.into();
|
let name = name.into();
|
||||||
crate::validation::non_empty(&name, "modelServer.name")?;
|
crate::validation::non_empty(&name, "modelServer.name")?;
|
||||||
|
options.validate()?;
|
||||||
|
validate_free_args(&args)?;
|
||||||
if auto_start {
|
if auto_start {
|
||||||
if kind != LocalModelServerKind::LlamaCpp {
|
if kind != LocalModelServerKind::LlamaCpp {
|
||||||
return Err(DomainError::Invariant(
|
return Err(DomainError::Invariant(
|
||||||
"modelServer.autoStart requires kind=LlamaCpp".to_owned(),
|
"modelServer.autoStart requires kind=LlamaCpp".to_owned(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if model.path.is_none() {
|
if model.source.is_none() {
|
||||||
return Err(DomainError::Invariant(
|
return Err(DomainError::Invariant(
|
||||||
"modelServer.autoStart requires model.path".to_owned(),
|
"modelServer.autoStart requires model.source".to_owned(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -274,6 +463,7 @@ impl LocalModelServerConfig {
|
|||||||
endpoint,
|
endpoint,
|
||||||
model,
|
model,
|
||||||
binary,
|
binary,
|
||||||
|
options,
|
||||||
args,
|
args,
|
||||||
auto_start,
|
auto_start,
|
||||||
stop_policy,
|
stop_policy,
|
||||||
@ -342,13 +532,12 @@ mod tests {
|
|||||||
fn endpoint_rejects_remote_host() {
|
fn endpoint_rejects_remote_host() {
|
||||||
let err = ModelServerEndpoint::new("http://example.com:8080", 8080).unwrap_err();
|
let err = ModelServerEndpoint::new("http://example.com:8080", 8080).unwrap_err();
|
||||||
assert!(err.to_string().contains("localhost"));
|
assert!(err.to_string().contains("localhost"));
|
||||||
let err =
|
let err = ModelServerEndpoint::new("http://127.0.0.1.evil.com:8080", 8080).unwrap_err();
|
||||||
ModelServerEndpoint::new("http://127.0.0.1.evil.com:8080", 8080).unwrap_err();
|
|
||||||
assert!(err.to_string().contains("localhost"));
|
assert!(err.to_string().contains("localhost"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn auto_start_requires_model_path() {
|
fn auto_start_requires_model_source() {
|
||||||
let endpoint = ModelServerEndpoint::new("http://localhost:8080/v1", 8080).unwrap();
|
let endpoint = ModelServerEndpoint::new("http://localhost:8080/v1", 8080).unwrap();
|
||||||
let model = LocalModelRef::new("qwen", "Qwen", None, "qwen").unwrap();
|
let model = LocalModelRef::new("qwen", "Qwen", None, "qwen").unwrap();
|
||||||
let err = LocalModelServerConfig::new(
|
let err = LocalModelServerConfig::new(
|
||||||
@ -358,11 +547,47 @@ mod tests {
|
|||||||
endpoint,
|
endpoint,
|
||||||
model,
|
model,
|
||||||
None,
|
None,
|
||||||
|
LlamaCppOptions::default(),
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
true,
|
true,
|
||||||
StopPolicy::StopOnAppExit,
|
StopPolicy::StopOnAppExit,
|
||||||
)
|
)
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(err.to_string().contains("model.path"));
|
assert!(err.to_string().contains("model.source"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hf_model_ref_accepts_repo_and_optional_quant() {
|
||||||
|
assert_eq!(
|
||||||
|
HfModelRef::new("Qwen/Qwen3-Coder:Q4_K_M").unwrap().as_str(),
|
||||||
|
"Qwen/Qwen3-Coder:Q4_K_M"
|
||||||
|
);
|
||||||
|
assert!(HfModelRef::new("Qwen/Qwen3 Coder").is_err());
|
||||||
|
assert!(HfModelRef::new("https://huggingface.co/Qwen/Qwen3").is_err());
|
||||||
|
assert!(HfModelRef::new("Qwen").is_err());
|
||||||
|
assert!(HfModelRef::new("a/b:c:d").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_free_args_rejects_reserved_flags_token_and_equals() {
|
||||||
|
for flag in [
|
||||||
|
"--model",
|
||||||
|
"-m",
|
||||||
|
"-hf",
|
||||||
|
"--port",
|
||||||
|
"--host",
|
||||||
|
"-ngl",
|
||||||
|
"--gpu-layers",
|
||||||
|
"-c",
|
||||||
|
"--ctx-size",
|
||||||
|
"--jinja",
|
||||||
|
] {
|
||||||
|
assert!(validate_free_args(&[flag.to_owned()]).is_err(), "{flag}");
|
||||||
|
assert!(
|
||||||
|
validate_free_args(&[format!("{flag}=value")]).is_err(),
|
||||||
|
"{flag}=value"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
validate_free_args(&["--threads".to_owned(), "8".to_owned()]).unwrap();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1014,6 +1014,15 @@ pub trait ManagedProcess: Send + Sync {
|
|||||||
|
|
||||||
/// Builds the argv-structured spawn spec for a local model server.
|
/// Builds the argv-structured spawn spec for a local model server.
|
||||||
pub trait ModelServerRuntime: Send + Sync {
|
pub trait ModelServerRuntime: Send + Sync {
|
||||||
|
/// Builds the pure argv contract for previewing or spawning a local server.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`ModelServerError`] if the config cannot be launched.
|
||||||
|
fn build_argv(
|
||||||
|
&self,
|
||||||
|
config: &LocalModelServerConfig,
|
||||||
|
) -> Result<ModelServerArgv, ModelServerError>;
|
||||||
|
|
||||||
/// Builds a spawn spec from a validated local-server config.
|
/// Builds a spawn spec from a validated local-server config.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
@ -1024,6 +1033,15 @@ pub trait ModelServerRuntime: Send + Sync {
|
|||||||
) -> Result<SpawnSpec, ModelServerError>;
|
) -> Result<SpawnSpec, ModelServerError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pure argv contract for a local model server invocation.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ModelServerArgv {
|
||||||
|
/// Executable command.
|
||||||
|
pub command: String,
|
||||||
|
/// Arguments without shell parsing.
|
||||||
|
pub args: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Persists local model-server configuration in the global IdeA store.
|
/// Persists local model-server configuration in the global IdeA store.
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait ModelServerRegistry: Send + Sync {
|
pub trait ModelServerRegistry: Send + Sync {
|
||||||
|
|||||||
@ -9,12 +9,16 @@ use async_trait::async_trait;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tokio::process::{Child, Command};
|
use tokio::process::{Child, Command};
|
||||||
|
|
||||||
use domain::model_server::{LocalModelServerConfig, LocalModelServerKind, ModelServerEndpoint};
|
use domain::model_server::{
|
||||||
use domain::ports::{
|
ExecutablePath, LlamaCppOptions, LocalModelRef, LocalModelServerConfig, LocalModelServerKind,
|
||||||
FileSystem, ManagedProcess, ManagedProcessHandle, ModelServerError, ModelServerProbe,
|
ModelPath, ModelServerEndpoint, ModelSource,
|
||||||
ModelServerRegistry, ModelServerRuntime, ProcessStatus, RemotePath, SpawnSpec,
|
|
||||||
};
|
};
|
||||||
use domain::{LocalModelServerId, ProjectPath};
|
use domain::ports::{
|
||||||
|
FileSystem, ManagedProcess, ManagedProcessHandle, ModelServerArgv, ModelServerError,
|
||||||
|
ModelServerProbe, ModelServerRegistry, ModelServerRuntime, ProcessStatus, RemotePath,
|
||||||
|
SpawnSpec,
|
||||||
|
};
|
||||||
|
use domain::{LocalModelServerId, ProjectPath, StopPolicy};
|
||||||
|
|
||||||
/// HTTP readiness probe for OpenAI-compatible servers.
|
/// HTTP readiness probe for OpenAI-compatible servers.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@ -77,19 +81,15 @@ impl LlamaCppRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ModelServerRuntime for LlamaCppRuntime {
|
impl ModelServerRuntime for LlamaCppRuntime {
|
||||||
fn build_spawn_spec(
|
fn build_argv(
|
||||||
&self,
|
&self,
|
||||||
config: &LocalModelServerConfig,
|
config: &LocalModelServerConfig,
|
||||||
) -> Result<SpawnSpec, ModelServerError> {
|
) -> Result<ModelServerArgv, ModelServerError> {
|
||||||
if config.kind != LocalModelServerKind::LlamaCpp {
|
if config.kind != LocalModelServerKind::LlamaCpp {
|
||||||
return Err(ModelServerError::Invalid(
|
return Err(ModelServerError::Invalid(
|
||||||
"only LlamaCpp local model servers are supported".to_owned(),
|
"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(
|
let command = resolve_binary(
|
||||||
config
|
config
|
||||||
.binary
|
.binary
|
||||||
@ -97,16 +97,49 @@ impl ModelServerRuntime for LlamaCppRuntime {
|
|||||||
.map(|binary| binary.as_str())
|
.map(|binary| binary.as_str())
|
||||||
.unwrap_or("llama-server"),
|
.unwrap_or("llama-server"),
|
||||||
)?;
|
)?;
|
||||||
let mut args = vec![
|
let mut args = Vec::new();
|
||||||
"--model".to_owned(),
|
match config
|
||||||
model_path.as_str().to_owned(),
|
.model
|
||||||
|
.source
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| ModelServerError::PathNotAccessible("model.source missing".to_owned()))?
|
||||||
|
{
|
||||||
|
ModelSource::LocalPath { path } => {
|
||||||
|
args.push("--model".to_owned());
|
||||||
|
args.push(path.as_str().to_owned());
|
||||||
|
}
|
||||||
|
ModelSource::HuggingFace { repo } => {
|
||||||
|
args.push("-hf".to_owned());
|
||||||
|
args.push(repo.as_str().to_owned());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
args.extend([
|
||||||
"--port".to_owned(),
|
"--port".to_owned(),
|
||||||
config.endpoint.port.to_string(),
|
config.endpoint.port.to_string(),
|
||||||
];
|
"--host".to_owned(),
|
||||||
|
config.options.host.clone(),
|
||||||
|
]);
|
||||||
|
if let Some(gpu_layers) = config.options.gpu_layers {
|
||||||
|
args.extend(["-ngl".to_owned(), gpu_layers.to_string()]);
|
||||||
|
}
|
||||||
|
if let Some(context_size) = config.options.context_size {
|
||||||
|
args.extend(["-c".to_owned(), context_size.to_string()]);
|
||||||
|
}
|
||||||
|
if config.options.jinja {
|
||||||
|
args.push("--jinja".to_owned());
|
||||||
|
}
|
||||||
args.extend(config.args.clone());
|
args.extend(config.args.clone());
|
||||||
|
Ok(ModelServerArgv { command, args })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_spawn_spec(
|
||||||
|
&self,
|
||||||
|
config: &LocalModelServerConfig,
|
||||||
|
) -> Result<SpawnSpec, ModelServerError> {
|
||||||
|
let argv = self.build_argv(config)?;
|
||||||
Ok(SpawnSpec {
|
Ok(SpawnSpec {
|
||||||
command,
|
command: argv.command,
|
||||||
args,
|
args: argv.args,
|
||||||
cwd: ProjectPath::new("/").map_err(|e| ModelServerError::Invalid(e.to_string()))?,
|
cwd: ProjectPath::new("/").map_err(|e| ModelServerError::Invalid(e.to_string()))?,
|
||||||
env: Vec::new(),
|
env: Vec::new(),
|
||||||
context_plan: None,
|
context_plan: None,
|
||||||
@ -207,7 +240,7 @@ impl ManagedProcess for LocalManagedProcess {
|
|||||||
|
|
||||||
/// File name of the global local-model-server registry.
|
/// File name of the global local-model-server registry.
|
||||||
const MODEL_SERVERS_FILE: &str = "model-servers.json";
|
const MODEL_SERVERS_FILE: &str = "model-servers.json";
|
||||||
const MODEL_SERVERS_VERSION: u32 = 1;
|
const MODEL_SERVERS_VERSION: u32 = 2;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@ -216,6 +249,82 @@ struct ModelServersDoc {
|
|||||||
servers: Vec<LocalModelServerConfig>,
|
servers: Vec<LocalModelServerConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
enum PersistedModelServersDoc {
|
||||||
|
V2(ModelServersDoc),
|
||||||
|
V1(ModelServersDocV1),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct ModelServersDocV1 {
|
||||||
|
servers: Vec<LocalModelServerConfigV1>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct LocalModelServerConfigV1 {
|
||||||
|
id: LocalModelServerId,
|
||||||
|
kind: LocalModelServerKind,
|
||||||
|
name: String,
|
||||||
|
endpoint: ModelServerEndpoint,
|
||||||
|
model: LocalModelRefV1,
|
||||||
|
#[serde(default)]
|
||||||
|
binary: Option<ExecutablePath>,
|
||||||
|
#[serde(default)]
|
||||||
|
args: Vec<String>,
|
||||||
|
auto_start: bool,
|
||||||
|
#[serde(default = "default_stop_policy")]
|
||||||
|
stop_policy: StopPolicy,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct LocalModelRefV1 {
|
||||||
|
id: String,
|
||||||
|
label: String,
|
||||||
|
#[serde(default)]
|
||||||
|
path: Option<ModelPath>,
|
||||||
|
served_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_stop_policy() -> StopPolicy {
|
||||||
|
StopPolicy::StopOnAppExit
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ModelServersDocV1> for ModelServersDoc {
|
||||||
|
fn from(doc: ModelServersDocV1) -> Self {
|
||||||
|
Self {
|
||||||
|
version: MODEL_SERVERS_VERSION,
|
||||||
|
servers: doc
|
||||||
|
.servers
|
||||||
|
.into_iter()
|
||||||
|
.map(|server| LocalModelServerConfig {
|
||||||
|
id: server.id,
|
||||||
|
kind: server.kind,
|
||||||
|
name: server.name,
|
||||||
|
endpoint: server.endpoint,
|
||||||
|
model: LocalModelRef {
|
||||||
|
id: server.model.id,
|
||||||
|
label: server.model.label,
|
||||||
|
source: server
|
||||||
|
.model
|
||||||
|
.path
|
||||||
|
.map(|path| ModelSource::LocalPath { path }),
|
||||||
|
served_name: server.model.served_name,
|
||||||
|
},
|
||||||
|
binary: server.binary,
|
||||||
|
options: LlamaCppOptions::default(),
|
||||||
|
args: server.args,
|
||||||
|
auto_start: server.auto_start,
|
||||||
|
stop_policy: server.stop_policy,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Default for ModelServersDoc {
|
impl Default for ModelServersDoc {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
@ -249,8 +358,15 @@ impl FsModelServerRegistry {
|
|||||||
|
|
||||||
async fn read_doc(&self) -> Result<ModelServersDoc, ModelServerError> {
|
async fn read_doc(&self) -> Result<ModelServersDoc, ModelServerError> {
|
||||||
match self.fs.read(&self.path()).await {
|
match self.fs.read(&self.path()).await {
|
||||||
Ok(bytes) => serde_json::from_slice(&bytes)
|
Ok(bytes) => match serde_json::from_slice::<PersistedModelServersDoc>(&bytes)
|
||||||
.map_err(|e| ModelServerError::Store(format!("serialization failed: {e}"))),
|
.map_err(|e| ModelServerError::Store(format!("serialization failed: {e}")))?
|
||||||
|
{
|
||||||
|
PersistedModelServersDoc::V2(doc) => Ok(ModelServersDoc {
|
||||||
|
version: MODEL_SERVERS_VERSION,
|
||||||
|
servers: doc.servers,
|
||||||
|
}),
|
||||||
|
PersistedModelServersDoc::V1(doc) => Ok(doc.into()),
|
||||||
|
},
|
||||||
Err(domain::ports::FsError::NotFound(_)) => Ok(ModelServersDoc::default()),
|
Err(domain::ports::FsError::NotFound(_)) => Ok(ModelServersDoc::default()),
|
||||||
Err(domain::ports::FsError::PermissionDenied(p)) => {
|
Err(domain::ports::FsError::PermissionDenied(p)) => {
|
||||||
Err(ModelServerError::PermissionDenied(p))
|
Err(ModelServerError::PermissionDenied(p))
|
||||||
@ -298,7 +414,14 @@ impl ModelServerRegistry for FsModelServerRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn save(&self, config: LocalModelServerConfig) -> Result<(), ModelServerError> {
|
async fn save(&self, config: LocalModelServerConfig) -> Result<(), ModelServerError> {
|
||||||
|
config
|
||||||
|
.options
|
||||||
|
.validate()
|
||||||
|
.map_err(|e| ModelServerError::Invalid(e.to_string()))?;
|
||||||
|
domain::model_server::validate_free_args(&config.args)
|
||||||
|
.map_err(|e| ModelServerError::Invalid(e.to_string()))?;
|
||||||
let mut doc = self.read_doc().await?;
|
let mut doc = self.read_doc().await?;
|
||||||
|
doc.version = MODEL_SERVERS_VERSION;
|
||||||
if let Some(slot) = doc.servers.iter_mut().find(|server| server.id == config.id) {
|
if let Some(slot) = doc.servers.iter_mut().find(|server| server.id == config.id) {
|
||||||
*slot = config;
|
*slot = config;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -4,8 +4,8 @@ use std::path::PathBuf;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use domain::model_server::{
|
use domain::model_server::{
|
||||||
ExecutablePath, LocalModelRef, LocalModelServerConfig, LocalModelServerKind, ModelPath,
|
ExecutablePath, HfModelRef, LlamaCppOptions, LocalModelRef, LocalModelServerConfig,
|
||||||
ModelServerEndpoint, StopPolicy,
|
LocalModelServerKind, ModelPath, ModelServerEndpoint, ModelSource, StopPolicy,
|
||||||
};
|
};
|
||||||
use domain::ports::{FileSystem, ModelServerRegistry, ModelServerRuntime, RemotePath};
|
use domain::ports::{FileSystem, ModelServerRegistry, ModelServerRuntime, RemotePath};
|
||||||
use domain::LocalModelServerId;
|
use domain::LocalModelServerId;
|
||||||
@ -53,12 +53,15 @@ fn config(id: LocalModelServerId, port: u16) -> LocalModelServerConfig {
|
|||||||
LocalModelRef::new(
|
LocalModelRef::new(
|
||||||
"qwen",
|
"qwen",
|
||||||
"Qwen",
|
"Qwen",
|
||||||
Some(ModelPath::new("/models/qwen.gguf").unwrap()),
|
Some(ModelSource::LocalPath {
|
||||||
|
path: ModelPath::new("/models/qwen.gguf").unwrap(),
|
||||||
|
}),
|
||||||
"qwen3-coder-30b",
|
"qwen3-coder-30b",
|
||||||
)
|
)
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
Some(ExecutablePath::new(binary).unwrap()),
|
Some(ExecutablePath::new(binary).unwrap()),
|
||||||
vec!["--ctx-size".to_owned(), "8192".to_owned()],
|
LlamaCppOptions::new("127.0.0.1", Some(35), Some(8192), true).unwrap(),
|
||||||
|
vec!["--threads".to_owned(), "8".to_owned()],
|
||||||
true,
|
true,
|
||||||
StopPolicy::StopOnAppExit,
|
StopPolicy::StopOnAppExit,
|
||||||
)
|
)
|
||||||
@ -67,23 +70,54 @@ fn config(id: LocalModelServerId, port: u16) -> LocalModelServerConfig {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn llamacpp_runtime_builds_structured_argv() {
|
fn llamacpp_runtime_builds_structured_argv() {
|
||||||
let spec = LlamaCppRuntime::new()
|
let argv = LlamaCppRuntime::new()
|
||||||
.build_spawn_spec(&config(sid(1), 8080))
|
.build_argv(&config(sid(1), 8080))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
spec.command,
|
argv.command,
|
||||||
std::env::current_exe().unwrap().to_string_lossy()
|
std::env::current_exe().unwrap().to_string_lossy()
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
spec.args,
|
argv.args,
|
||||||
vec![
|
vec![
|
||||||
"--model",
|
"--model",
|
||||||
"/models/qwen.gguf",
|
"/models/qwen.gguf",
|
||||||
"--port",
|
"--port",
|
||||||
"8080",
|
"8080",
|
||||||
"--ctx-size",
|
"--host",
|
||||||
"8192"
|
"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());
|
assert!(spec.context_plan.is_none());
|
||||||
@ -109,3 +143,49 @@ async fn fs_model_server_registry_roundtrips_global_json() {
|
|||||||
registry.save(updated.clone()).await.unwrap();
|
registry.save(updated.clone()).await.unwrap();
|
||||||
assert_eq!(registry.list().await.unwrap(), vec![updated]);
|
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].args, vec!["--ctx-size", "4096"]);
|
||||||
|
assert_eq!(
|
||||||
|
servers[0].model.source,
|
||||||
|
Some(ModelSource::LocalPath {
|
||||||
|
path: ModelPath::new("/models/qwen.gguf").unwrap()
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user