From 38aecf2cac479d51b5dbf636fd43075507cfc791 Mon Sep 17 00:00:00 2001 From: Blomios Date: Sun, 12 Jul 2026 14:31:54 +0200 Subject: [PATCH] =?UTF-8?q?feat(model-server):=20source=20Hugging=20Face?= =?UTF-8?q?=20(-hf)=20+=20options=20structur=C3=A9es=20llama.cpp=20+=20mig?= =?UTF-8?q?ration=20store=20V2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/app-tauri/src/commands.rs | 77 ++++-- crates/app-tauri/src/dto.rs | 140 ++++++++-- crates/app-tauri/src/lib.rs | 1 + crates/app-tauri/tests/dto_model_servers.rs | 114 +++++++- crates/application/src/model_server.rs | 10 +- crates/application/tests/model_server.rs | 48 +++- crates/domain/src/lib.rs | 6 +- crates/domain/src/model_server.rs | 245 +++++++++++++++++- crates/domain/src/ports.rs | 18 ++ crates/infrastructure/src/model_server/mod.rs | 163 ++++++++++-- crates/infrastructure/tests/model_server.rs | 100 ++++++- 11 files changed, 820 insertions(+), 102 deletions(-) diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index f884996..9bcbb3d 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -26,12 +26,14 @@ use application::{ UnassignSkillFromAgentInput, UpdateAgentContextInput, UpdateAgentPermissionsInput, UpdateMemoryInput, UpdateProjectContextInput, UpdateProjectPermissionsInput, UpdateSkillInput, }; +use domain::ports::ModelServerRuntime; use domain::ports::PtyHandle; use crate::dto::{ - parse_agent_id, parse_close_terminal, parse_delete_profile, parse_layout_id, parse_memory_slug, - parse_node_id, parse_profile_id, parse_project_id, parse_session_id, parse_skill_id, - parse_task_id, parse_template_id, parse_ticket_id, AgentDriftListDto, AgentDto, AgentListDto, + model_server_config_domain, parse_agent_id, parse_close_terminal, parse_delete_profile, + parse_layout_id, parse_memory_slug, parse_model_server_id, parse_node_id, parse_profile_id, + 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, BackgroundTaskDto, ChangeAgentProfileDto, ChangeAgentProfileRequestDto, CloneOpenCodeProfileFromSeedRequestDto, ConfigureProfilesRequestDto, ConversationDetailsDto, @@ -45,19 +47,19 @@ use crate::dto::{ GraphCommitListDto, HealthRequestDto, HealthResponseDto, InspectConversationRequestDto, InterruptAgentRequestDto, LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto, LiveAgentListDto, MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, - ModelServerConfigDto, ModelServerConfigListDto, OpenTerminalRequestDto, ProfileDto, - ProfileListDto, ProjectDto, ProjectListDto, ProjectPermissionsDto, ProjectWorkStateDto, - ReadAgentContextResponseDto, ReadConversationPageRequestDto, ReattachChatDto, - ReattachResultDto, RecallMemoryRequestDto, RenameLayoutRequestDto, ReplyChunk, - ResizeTerminalRequestDto, ResolveAgentPermissionsRequestDto, ResumableAgentListDto, - SaveEmbedderProfileRequestDto, SaveModelServerRequestDto, SaveProfileRequestDto, - SetActiveLayoutRequestDto, SetActiveLayoutResultDto, SkillDto, SkillListDto, - StopLiveAgentRequestDto, StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto, - SyncResultDto, TemplateDto, TemplateListDto, TerminalClosedDto, TerminalSessionDto, - TurnPageDto, UnassignSkillRequestDto, UpdateAgentContextRequestDto, - UpdateAgentPermissionsRequestDto, UpdateMemoryRequestDto, UpdateProjectContextRequestDto, - UpdateProjectPermissionsRequestDto, UpdateSkillRequestDto, UpdateTemplateRequestDto, - WriteTerminalRequestDto, parse_model_server_id, save_model_server_input, + ModelServerConfigDto, ModelServerConfigListDto, OpenTerminalRequestDto, + PreviewModelServerCommandDto, ProfileDto, ProfileListDto, ProjectDto, ProjectListDto, + ProjectPermissionsDto, ProjectWorkStateDto, ReadAgentContextResponseDto, + ReadConversationPageRequestDto, ReattachChatDto, ReattachResultDto, RecallMemoryRequestDto, + RenameLayoutRequestDto, ReplyChunk, ResizeTerminalRequestDto, + ResolveAgentPermissionsRequestDto, ResumableAgentListDto, SaveEmbedderProfileRequestDto, + SaveModelServerRequestDto, SaveProfileRequestDto, SetActiveLayoutRequestDto, + SetActiveLayoutResultDto, SkillDto, SkillListDto, StopLiveAgentRequestDto, + StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, + TemplateListDto, TerminalClosedDto, TerminalSessionDto, TurnPageDto, UnassignSkillRequestDto, + UpdateAgentContextRequestDto, UpdateAgentPermissionsRequestDto, UpdateMemoryRequestDto, + UpdateProjectContextRequestDto, UpdateProjectPermissionsRequestDto, UpdateSkillRequestDto, + UpdateTemplateRequestDto, WriteTerminalRequestDto, }; use crate::pty::{PtyBridge, PtyChunk}; use crate::state::AppState; @@ -900,6 +902,28 @@ pub async fn save_model_server( .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 { + 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. /// /// # 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::>() + .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) // --------------------------------------------------------------------------- diff --git a/crates/app-tauri/src/dto.rs b/crates/app-tauri/src/dto.rs index 4f027d6..bf7e91a 100644 --- a/crates/app-tauri/src/dto.rs +++ b/crates/app-tauri/src/dto.rs @@ -913,8 +913,8 @@ impl From for FirstRunStateDto { use application::{ListModelServersOutput, SaveModelServerInput, SaveModelServerOutput}; use domain::model_server::{ - ExecutablePath, LocalModelRef, LocalModelServerConfig, LocalModelServerKind, ModelPath, - ModelServerEndpoint, + ExecutablePath, HfModelRef, LlamaCppOptions, LocalModelRef, LocalModelServerConfig, + LocalModelServerKind, ModelPath, ModelServerEndpoint, ModelSource, }; use domain::{LocalModelServerId, StopPolicy}; @@ -974,6 +974,44 @@ impl From 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 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 { + 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. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -989,14 +1027,29 @@ pub struct ModelServerConfigDto { pub base_url: String, /// TCP port. pub port: u16, - /// Optional explicit `.gguf` model path. + /// Optional explicit model source. #[serde(default, skip_serializing_if = "Option::is_none")] + pub model_source: Option, + /// Legacy V1 input alias for `modelSource:{type:"localPath",path}`. + #[serde(default, skip_serializing)] pub model_path: Option, /// Served model name exposed to OpenCode. pub served_model_name: String, /// Optional explicit `llama-server` executable path. #[serde(default, skip_serializing_if = "Option::is_none")] pub binary_path: Option, + /// 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, + /// llama.cpp context size passed to `-c`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub context_size: Option, + /// Whether to pass `--jinja`. + #[serde(default)] + pub jinja: bool, /// Extra argv entries. #[serde(default)] pub args: Vec, @@ -1016,12 +1069,14 @@ impl ModelServerConfigDto { name: config.name, base_url: config.endpoint.base_url, port: config.endpoint.port, - model_path: config - .model - .path - .map(|path| path.as_str().to_owned()), + model_source: config.model.source.map(Into::into), + model_path: None, served_model_name: config.model.served_name, 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, auto_start: config.auto_start, stop_policy: config.stop_policy.into(), @@ -1040,26 +1095,21 @@ impl ModelServerConfigDto { let server_id = parse_model_server_id(&self.id)?; let endpoint = ModelServerEndpoint::new(self.base_url.clone(), self.port) .map_err(invalid_domain_error)?; - let model_path = optional_non_empty(self.model_path) - .map(ModelPath::new) - .transpose() - .map_err(invalid_domain_error)?; + let source = resolve_model_source(self.model_source, self.model_path)?; let model_id = existing .filter(|config| config.id == server_id) .map(|config| config.model.id.clone()) .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); let label = non_empty_or_fallback(&self.served_model_name, &self.name); - let model = LocalModelRef::new( - model_id, - label, - model_path, - self.served_model_name.clone(), - ) - .map_err(invalid_domain_error)?; + let model = LocalModelRef::new(model_id, label, source, self.served_model_name.clone()) + .map_err(invalid_domain_error)?; let binary = optional_non_empty(self.binary_path) .map(ExecutablePath::new) .transpose() .map_err(invalid_domain_error)?; + let options = + LlamaCppOptions::new(self.host, self.gpu_layers, self.context_size, self.jinja) + .map_err(invalid_domain_error)?; LocalModelServerConfig::new( server_id, self.kind.into(), @@ -1067,6 +1117,7 @@ impl ModelServerConfigDto { endpoint, model, binary, + options, self.args, self.auto_start, 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, + /// Human-readable escaped command line. + pub display: String, +} + /// List response for local model servers. #[derive(Debug, Clone, Serialize)] #[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 { + config.into_domain(existing) +} + +fn resolve_model_source( + model_source: Option, + model_path: Option, +) -> Result, 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) -> Option { raw.map(|value| value.trim().to_owned()) .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 { let primary = primary.trim(); if primary.is_empty() { diff --git a/crates/app-tauri/src/lib.rs b/crates/app-tauri/src/lib.rs index fca719d..6ebcf91 100644 --- a/crates/app-tauri/src/lib.rs +++ b/crates/app-tauri/src/lib.rs @@ -164,6 +164,7 @@ pub fn run() { commands::configure_profiles, commands::list_model_servers, commands::save_model_server, + commands::preview_model_server_command, commands::delete_model_server, commands::list_embedder_profiles, commands::save_embedder_profile, diff --git a/crates/app-tauri/tests/dto_model_servers.rs b/crates/app-tauri/tests/dto_model_servers.rs index 81dc85e..300aa19 100644 --- a/crates/app-tauri/tests/dto_model_servers.rs +++ b/crates/app-tauri/tests/dto_model_servers.rs @@ -1,12 +1,12 @@ //! DTO contract tests for local model-server IPC. use app_tauri_lib::dto::{ - ModelServerConfigDto, ModelServerKindDto, SaveModelServerRequestDto, StopPolicyDto, - save_model_server_input, + save_model_server_input, ModelServerConfigDto, ModelServerKindDto, ModelSourceDto, + SaveModelServerRequestDto, StopPolicyDto, }; use domain::model_server::{ - ExecutablePath, LocalModelRef, LocalModelServerConfig, LocalModelServerKind, ModelPath, - ModelServerEndpoint, + ExecutablePath, LlamaCppOptions, LocalModelRef, LocalModelServerConfig, LocalModelServerKind, + ModelPath, ModelServerEndpoint, ModelSource, }; use domain::{LocalModelServerId, StopPolicy}; use serde_json::json; @@ -25,12 +25,15 @@ fn domain_config(id: LocalModelServerId, model_id: &str) -> LocalModelServerConf LocalModelRef::new( model_id, "Qwen", - Some(ModelPath::new("/models/qwen.gguf").unwrap()), + Some(ModelSource::LocalPath { + path: ModelPath::new("/models/qwen.gguf").unwrap(), + }), "qwen3-coder", ) .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, 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["kind"], "llamaCpp"); 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["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!(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] @@ -65,7 +77,9 @@ fn model_server_dto_deserialises_flat_wire_shape_and_generates_model_id() { "modelPath": "/models/qwen.gguf", "servedModelName": "qwen3-coder", "binaryPath": "/usr/bin/llama-server", - "args": ["--ctx-size", "4096"], + "gpuLayers": 35, + "contextSize": 4096, + "args": ["--threads", "8"], "autoStart": true, "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.endpoint.base_url, "http://localhost:8081/v1"); assert_eq!( - input.config.model.path.unwrap().as_str(), - "/models/qwen.gguf" + input.config.model.source, + 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.label, "qwen3-coder"); 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(), base_url: "http://localhost:8082/v1".to_owned(), 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(), 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(), auto_start: true, 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.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" + ); +} diff --git a/crates/application/src/model_server.rs b/crates/application/src/model_server.rs index 4b3958c..fd0ca51 100644 --- a/crates/application/src/model_server.rs +++ b/crates/application/src/model_server.rs @@ -7,6 +7,7 @@ use std::time::Duration; use domain::events::DomainEvent; use domain::model_server::{ LocalModelServerConfig, ModelServerLifecycleStatus, ModelServerReady, ModelServerStatus, + ModelSource, }; use domain::ports::{ EventBus, FileSystem, ManagedProcess, ManagedProcessHandle, ModelServerError, ModelServerProbe, @@ -332,11 +333,14 @@ impl EnsureLocalModelServer { &self, config: &LocalModelServerConfig, ) -> Result<(), AppError> { - let Some(path) = config.model.path.as_ref() else { - let err = ModelServerError::PathNotAccessible("model.path missing".to_owned()); + let Some(ModelSource::LocalPath { path }) = config.model.source.as_ref() else { + return Ok(()); + }; + if path.as_str().is_empty() { + let err = ModelServerError::PathNotAccessible("model.source.path missing".to_owned()); self.publish_failure(config.id, &err); return Err(err.into()); - }; + } match self .fs .exists(&RemotePath::new(path.as_str().to_owned())) diff --git a/crates/application/tests/model_server.rs b/crates/application/tests/model_server.rs index 3f5fe15..3fd9981 100644 --- a/crates/application/tests/model_server.rs +++ b/crates/application/tests/model_server.rs @@ -12,13 +12,13 @@ use application::{ }; use domain::events::DomainEvent; use domain::model_server::{ - ExecutablePath, LocalModelRef, LocalModelServerConfig, LocalModelServerKind, ModelPath, - ModelServerEndpoint, ModelServerStatus, StopPolicy, + ExecutablePath, LlamaCppOptions, LocalModelRef, LocalModelServerConfig, LocalModelServerKind, + ModelPath, ModelServerEndpoint, ModelServerStatus, ModelSource, StopPolicy, }; use domain::ports::{ DirEntry, EventBus, EventStream, FileSystem, FsError, ManagedProcess, ManagedProcessHandle, - ModelServerError, ModelServerProbe, ModelServerRegistry, ModelServerRuntime, ProcessStatus, - ProfileStore, RemotePath, SpawnSpec, StoreError, + ModelServerArgv, ModelServerError, ModelServerProbe, ModelServerRegistry, ModelServerRuntime, + ProcessStatus, ProfileStore, RemotePath, SpawnSpec, StoreError, }; use domain::profile::{AgentProfile, ContextInjection, OpenCodeConfig, StructuredAdapter}; use domain::{LocalModelServerId, ProfileId, ProjectPath}; @@ -41,11 +41,14 @@ fn config( LocalModelRef::new( "qwen", "Qwen", - Some(ModelPath::new(path).unwrap()), + Some(ModelSource::LocalPath { + path: ModelPath::new(path).unwrap(), + }), "qwen3-coder-30b", ) .unwrap(), Some(ExecutablePath::new("llama-server").unwrap()), + LlamaCppOptions::default(), Vec::new(), auto_start, StopPolicy::StopOnAppExit, @@ -192,11 +195,16 @@ impl ManagedProcess for FakeProcess { struct FakeRuntime; impl ModelServerRuntime for FakeRuntime { - fn build_spawn_spec( + fn build_argv( &self, config: &LocalModelServerConfig, - ) -> Result { - Ok(SpawnSpec { + ) -> Result { + let Some(ModelSource::LocalPath { path }) = config.model.source.as_ref() else { + return Err(ModelServerError::PathNotAccessible( + "model.source missing".to_owned(), + )); + }; + Ok(ModelServerArgv { command: config .binary .as_ref() @@ -204,10 +212,23 @@ impl ModelServerRuntime for FakeRuntime { .unwrap_or_else(|| "llama-server".to_owned()), args: vec![ "--model".to_owned(), - config.model.path.as_ref().unwrap().as_str().to_owned(), + path.as_str().to_owned(), "--port".to_owned(), config.endpoint.port.to_string(), + "--host".to_owned(), + config.options.host.clone(), ], + }) + } + + fn build_spawn_spec( + &self, + config: &LocalModelServerConfig, + ) -> Result { + let argv = self.build_argv(config)?; + Ok(SpawnSpec { + command: argv.command, + args: argv.args, cwd: ProjectPath::new("/").unwrap(), env: Vec::new(), 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].args, - vec!["--model", "/models/qwen.gguf", "--port", "8081"] + vec![ + "--model", + "/models/qwen.gguf", + "--port", + "8081", + "--host", + "127.0.0.1" + ] ); } diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index b009457..3b76b10 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -155,9 +155,9 @@ pub use memory_harvest::{ }; pub use model_server::{ - ExecutablePath, LocalModelRef, LocalModelServerConfig, LocalModelServerKind, ModelPath, - ModelServerEndpoint, ModelServerLifecycleStatus, ModelServerReady, ModelServerStatus, - StopPolicy, + validate_free_args, ExecutablePath, HfModelRef, LlamaCppOptions, LocalModelRef, + LocalModelServerConfig, LocalModelServerKind, ModelPath, ModelServerEndpoint, + ModelServerLifecycleStatus, ModelServerReady, ModelServerStatus, ModelSource, StopPolicy, }; pub use remote::{RemoteKind, RemoteRef, SshAuth}; diff --git a/crates/domain/src/model_server.rs b/crates/domain/src/model_server.rs index a2816de..f749eb1 100644 --- a/crates/domain/src/model_server.rs +++ b/crates/domain/src/model_server.rs @@ -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) -> Result { + 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. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(transparent)] @@ -159,9 +234,9 @@ pub struct LocalModelRef { pub id: String, /// Human display label. 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")] - pub path: Option, + pub source: Option, /// Name exposed by the server and used in `OpenCodeConfig.model`. pub served_name: String, } @@ -174,7 +249,7 @@ impl LocalModelRef { pub fn new( id: impl Into, label: impl Into, - path: Option, + source: Option, served_name: impl Into, ) -> Result { let id = id.into(); @@ -186,12 +261,121 @@ impl LocalModelRef { Ok(Self { id, label, - path, + source, 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, + /// Context size passed to `-c`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub context_size: Option, + /// 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, + gpu_layers: Option, + context_size: Option, + jinja: bool, + ) -> Result { + 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. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -221,6 +405,8 @@ pub struct LocalModelServerConfig { /// Optional executable path or bare command name. #[serde(default, skip_serializing_if = "Option::is_none")] pub binary: Option, + /// Structured llama.cpp options. + pub options: LlamaCppOptions, /// Extra argv passed to the runtime. #[serde(default)] pub args: Vec, @@ -249,21 +435,24 @@ impl LocalModelServerConfig { endpoint: ModelServerEndpoint, model: LocalModelRef, binary: Option, + options: LlamaCppOptions, args: Vec, auto_start: bool, stop_policy: StopPolicy, ) -> Result { let name = name.into(); crate::validation::non_empty(&name, "modelServer.name")?; + options.validate()?; + validate_free_args(&args)?; if auto_start { if kind != LocalModelServerKind::LlamaCpp { return Err(DomainError::Invariant( "modelServer.autoStart requires kind=LlamaCpp".to_owned(), )); } - if model.path.is_none() { + if model.source.is_none() { 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, model, binary, + options, args, auto_start, stop_policy, @@ -342,13 +532,12 @@ mod tests { 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(); + 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() { + fn auto_start_requires_model_source() { let endpoint = ModelServerEndpoint::new("http://localhost:8080/v1", 8080).unwrap(); let model = LocalModelRef::new("qwen", "Qwen", None, "qwen").unwrap(); let err = LocalModelServerConfig::new( @@ -358,11 +547,47 @@ mod tests { endpoint, model, None, + LlamaCppOptions::default(), Vec::new(), true, StopPolicy::StopOnAppExit, ) .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(); } } diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index b0f3805..eee0b6a 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -1014,6 +1014,15 @@ pub trait ManagedProcess: Send + Sync { /// Builds the argv-structured spawn spec for a local model server. 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; + /// Builds a spawn spec from a validated local-server config. /// /// # Errors @@ -1024,6 +1033,15 @@ pub trait ModelServerRuntime: Send + Sync { ) -> Result; } +/// 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, +} + /// Persists local model-server configuration in the global IdeA store. #[async_trait] pub trait ModelServerRegistry: Send + Sync { diff --git a/crates/infrastructure/src/model_server/mod.rs b/crates/infrastructure/src/model_server/mod.rs index df0014e..852307f 100644 --- a/crates/infrastructure/src/model_server/mod.rs +++ b/crates/infrastructure/src/model_server/mod.rs @@ -9,12 +9,16 @@ use async_trait::async_trait; use serde::{Deserialize, Serialize}; use tokio::process::{Child, Command}; -use domain::model_server::{LocalModelServerConfig, LocalModelServerKind, ModelServerEndpoint}; -use domain::ports::{ - FileSystem, ManagedProcess, ManagedProcessHandle, ModelServerError, ModelServerProbe, - ModelServerRegistry, ModelServerRuntime, ProcessStatus, RemotePath, SpawnSpec, +use domain::model_server::{ + ExecutablePath, LlamaCppOptions, LocalModelRef, LocalModelServerConfig, LocalModelServerKind, + ModelPath, ModelServerEndpoint, ModelSource, }; -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. #[derive(Clone)] @@ -77,19 +81,15 @@ impl LlamaCppRuntime { } impl ModelServerRuntime for LlamaCppRuntime { - fn build_spawn_spec( + fn build_argv( &self, config: &LocalModelServerConfig, - ) -> Result { + ) -> Result { if config.kind != LocalModelServerKind::LlamaCpp { return Err(ModelServerError::Invalid( "only LlamaCpp local model servers are supported".to_owned(), )); } - let model_path = - config.model.path.as_ref().ok_or_else(|| { - ModelServerError::PathNotAccessible("model.path missing".to_owned()) - })?; let command = resolve_binary( config .binary @@ -97,16 +97,49 @@ impl ModelServerRuntime for LlamaCppRuntime { .map(|binary| binary.as_str()) .unwrap_or("llama-server"), )?; - let mut args = vec![ - "--model".to_owned(), - model_path.as_str().to_owned(), + let mut args = Vec::new(); + match config + .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(), 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()); + Ok(ModelServerArgv { command, args }) + } + + fn build_spawn_spec( + &self, + config: &LocalModelServerConfig, + ) -> Result { + let argv = self.build_argv(config)?; Ok(SpawnSpec { - command, - args, + command: argv.command, + args: argv.args, cwd: ProjectPath::new("/").map_err(|e| ModelServerError::Invalid(e.to_string()))?, env: Vec::new(), context_plan: None, @@ -207,7 +240,7 @@ impl ManagedProcess for LocalManagedProcess { /// File name of the global local-model-server registry. 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)] #[serde(rename_all = "camelCase")] @@ -216,6 +249,82 @@ struct ModelServersDoc { servers: Vec, } +#[derive(Debug, Clone, Deserialize)] +#[serde(untagged)] +enum PersistedModelServersDoc { + V2(ModelServersDoc), + V1(ModelServersDocV1), +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ModelServersDocV1 { + servers: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct LocalModelServerConfigV1 { + id: LocalModelServerId, + kind: LocalModelServerKind, + name: String, + endpoint: ModelServerEndpoint, + model: LocalModelRefV1, + #[serde(default)] + binary: Option, + #[serde(default)] + args: Vec, + 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, + served_name: String, +} + +fn default_stop_policy() -> StopPolicy { + StopPolicy::StopOnAppExit +} + +impl From 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 { fn default() -> Self { Self { @@ -249,8 +358,15 @@ impl FsModelServerRegistry { async fn read_doc(&self) -> Result { match self.fs.read(&self.path()).await { - Ok(bytes) => serde_json::from_slice(&bytes) - .map_err(|e| ModelServerError::Store(format!("serialization failed: {e}"))), + Ok(bytes) => match serde_json::from_slice::(&bytes) + .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::PermissionDenied(p)) => { Err(ModelServerError::PermissionDenied(p)) @@ -298,7 +414,14 @@ impl ModelServerRegistry for FsModelServerRegistry { } 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?; + doc.version = MODEL_SERVERS_VERSION; if let Some(slot) = doc.servers.iter_mut().find(|server| server.id == config.id) { *slot = config; } else { diff --git a/crates/infrastructure/tests/model_server.rs b/crates/infrastructure/tests/model_server.rs index 997959c..c9cac18 100644 --- a/crates/infrastructure/tests/model_server.rs +++ b/crates/infrastructure/tests/model_server.rs @@ -4,8 +4,8 @@ use std::path::PathBuf; use std::sync::Arc; use domain::model_server::{ - ExecutablePath, LocalModelRef, LocalModelServerConfig, LocalModelServerKind, ModelPath, - ModelServerEndpoint, StopPolicy, + ExecutablePath, HfModelRef, LlamaCppOptions, LocalModelRef, LocalModelServerConfig, + LocalModelServerKind, ModelPath, ModelServerEndpoint, ModelSource, StopPolicy, }; use domain::ports::{FileSystem, ModelServerRegistry, ModelServerRuntime, RemotePath}; use domain::LocalModelServerId; @@ -53,12 +53,15 @@ fn config(id: LocalModelServerId, port: u16) -> LocalModelServerConfig { LocalModelRef::new( "qwen", "Qwen", - Some(ModelPath::new("/models/qwen.gguf").unwrap()), + Some(ModelSource::LocalPath { + path: ModelPath::new("/models/qwen.gguf").unwrap(), + }), "qwen3-coder-30b", ) .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, StopPolicy::StopOnAppExit, ) @@ -67,23 +70,54 @@ fn config(id: LocalModelServerId, port: u16) -> LocalModelServerConfig { #[test] fn llamacpp_runtime_builds_structured_argv() { - let spec = LlamaCppRuntime::new() - .build_spawn_spec(&config(sid(1), 8080)) + let argv = LlamaCppRuntime::new() + .build_argv(&config(sid(1), 8080)) .unwrap(); assert_eq!( - spec.command, + argv.command, std::env::current_exe().unwrap().to_string_lossy() ); assert_eq!( - spec.args, + argv.args, vec![ "--model", "/models/qwen.gguf", "--port", "8080", - "--ctx-size", - "8192" + "--host", + "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()); @@ -109,3 +143,49 @@ async fn fs_model_server_registry_roundtrips_global_json() { registry.save(updated.clone()).await.unwrap(); 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 = 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() + }) + ); +}