From 38aecf2cac479d51b5dbf636fd43075507cfc791 Mon Sep 17 00:00:00 2001 From: Blomios Date: Sun, 12 Jul 2026 14:31:54 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(model-server):=20source=20Hugging=20Fa?= =?UTF-8?q?ce=20(-hf)=20+=20options=20structur=C3=A9es=20llama.cpp=20+=20m?= =?UTF-8?q?igration=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() + }) + ); +} From efa081b07404245eb8d885a660340e1590261bfa Mon Sep 17 00:00:00 2001 From: Blomios Date: Sun, 12 Jul 2026 14:32:04 +0200 Subject: [PATCH 2/2] =?UTF-8?q?feat(model-server):=20wizard=20V2=20config?= =?UTF-8?q?=20mod=C3=A8les=20locaux=20=E2=80=94=20source,=20options=20stru?= =?UTF-8?q?ctur=C3=A9es=20&=20preview?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refonte UX du wizard de configuration des modèles locaux du serveur llama.cpp intégré, alignée sur la source Hugging Face et les options backend. - choix de source sans jargon (chemin local .gguf vs dépôt Hugging Face) ; - champs structurés -ngl (gpu_layers), -c (context_size), --jinja, --host ; - zone d'arguments libres avec alerte sur les flags réservés ; - preview de la commande via le backend (previewModelServerCommand, debounced) ; - gateway/ports et mock adaptés au contrat V2. QA : Vitest 22 verts, tsc/build OK. Co-Authored-By: Claude Opus 4.8 --- frontend/src/adapters/mock/index.ts | 39 ++ frontend/src/adapters/modelServer.ts | 15 +- frontend/src/domain/index.ts | 40 +- .../first-run/FirstRunWizard.test.tsx | 2 + .../model-servers/ModelServersPanel.tsx | 495 ++++++++++++++---- frontend/src/features/model-servers/index.ts | 5 + .../src/features/model-servers/modelServer.ts | 120 ++++- .../model-servers/modelServers.test.tsx | 177 ++++++- .../features/model-servers/useModelServers.ts | 29 +- frontend/src/ports/index.ts | 15 +- 10 files changed, 794 insertions(+), 143 deletions(-) diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index 9ff1d29..8908d45 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -24,6 +24,7 @@ import type { LayoutOperation, LayoutTree, LocalModelServerConfig, + ModelServerCommandPreview, Memory, MemoryIndexEntry, MemoryLink, @@ -1284,6 +1285,44 @@ export class MockModelServerGateway implements ModelServerGateway { this.servers = this.servers.filter((s) => s.id !== serverId); } + async previewModelServerCommand( + config: LocalModelServerConfig, + ): Promise { + // Mirror of the backend `LlamaCppRuntime::build_argv` (order matters), kept + // deliberately simple: it exists so the UI preview flow is testable, never + // as the source of truth (production uses the real backend command). + if (!config.modelSource) { + const err: GatewayError = { + code: "INVALID", + message: "model.source missing", + }; + throw err; + } + const command = + config.binaryPath && config.binaryPath.trim().length > 0 + ? config.binaryPath + : "llama-server"; + const args: string[] = []; + if (config.modelSource.type === "localPath") { + args.push("--model", config.modelSource.path); + } else { + args.push("-hf", config.modelSource.repo); + } + args.push("--port", String(config.port), "--host", config.host); + if (config.gpuLayers !== undefined) { + args.push("-ngl", String(config.gpuLayers)); + } + if (config.contextSize !== undefined) { + args.push("-c", String(config.contextSize)); + } + if (config.jinja) args.push("--jinja"); + args.push(...config.args); + const display = [command, ...args] + .map((part) => (/\s/.test(part) ? `'${part}'` : part)) + .join(" "); + return { command, args, display }; + } + /** Test hook: mark a server id as still referenced (delete then rejects). */ markInUse(serverId: string): void { this.inUse.add(serverId); diff --git a/frontend/src/adapters/modelServer.ts b/frontend/src/adapters/modelServer.ts index b2123b4..72a1d75 100644 --- a/frontend/src/adapters/modelServer.ts +++ b/frontend/src/adapters/modelServer.ts @@ -9,7 +9,10 @@ import { invoke } from "@tauri-apps/api/core"; -import type { LocalModelServerConfig } from "@/domain"; +import type { + LocalModelServerConfig, + ModelServerCommandPreview, +} from "@/domain"; import type { ModelServerGateway } from "@/ports"; export class TauriModelServerGateway implements ModelServerGateway { @@ -28,4 +31,14 @@ export class TauriModelServerGateway implements ModelServerGateway { async deleteModelServer(serverId: string): Promise { await invoke("delete_model_server", { serverId }); } + + previewModelServerCommand( + config: LocalModelServerConfig, + ): Promise { + // `preview_model_server_command` takes the config directly (no `request` + // wrapper), unlike `save_model_server`. + return invoke("preview_model_server_command", { + config, + }); + } } diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index a74bc36..6e9a902 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -39,6 +39,17 @@ export type ModelServerStatus = */ export type StopPolicy = "keepAlive" | "stopOnAppExit" | "stopWhenUnused"; +/** + * Where the served `.gguf` model comes from (F35 V2, mirror of the backend + * `ModelSourceDto`, camelCase wire, tagged on `type`): + * - `localPath`: an absolute `.gguf` path already present on disk (`--model`), + * - `huggingFace`: a `namespace/repo[:quant]` reference llama.cpp downloads and + * caches automatically (`-hf`). + */ +export type ModelSource = + | { type: "localPath"; path: string } + | { type: "huggingFace"; repo: string }; + /** * A declared local model server (F35, mirror of the backend flat * `LocalModelServerConfigDto`, camelCase wire). Global to IdeA (not project @@ -50,7 +61,8 @@ export type StopPolicy = "keepAlive" | "stopOnAppExit" | "stopWhenUnused"; * UUID; it does not generate the server id, only an internal model id it hides). * - `servedModelName` is what the user configures and what is sent to OpenCode as * the `model` — the backend's internal `model.id`/`model.label` are not exposed. - * - `modelPath` is required by the backend when `autoStart` is `true`. + * - `modelSource` (V2) replaces the former `modelPath`; it is required by the + * backend when `autoStart` is `true`. */ export interface LocalModelServerConfig { id: string; @@ -60,17 +72,39 @@ export interface LocalModelServerConfig { /** OpenAI-compatible base URL served by `llama-server` (normalised to `/v1`). */ baseURL: string; port: number; - /** Absolute `.gguf` path. Required when `autoStart` is `true`. */ - modelPath?: string; + /** Explicit model source (`.gguf` path or Hugging Face ref). Required for auto-start. */ + modelSource?: ModelSource; /** Model name exposed by the server and sent to OpenCode as `model`. */ servedModelName: string; /** Explicit `llama-server` executable path/command (optional). */ binaryPath?: string; + /** llama.cpp `--host` (default `127.0.0.1`). */ + host: string; + /** llama.cpp `-ngl` GPU layers (optional; `0` = CPU only). */ + gpuLayers?: number; + /** llama.cpp `-c` context size (optional). */ + contextSize?: number; + /** Whether to pass `--jinja` (Jinja chat template). */ + jinja: boolean; args: string[]; autoStart: boolean; stopPolicy: StopPolicy; } +/** + * A previewed `llama-server` command line (F35 V2, mirror of the backend + * `PreviewModelServerCommandDto`). Built by the backend from a draft config — + * never reconstructed client-side. + */ +export interface ModelServerCommandPreview { + /** Resolved executable (e.g. `llama-server` or an absolute path). */ + command: string; + /** Argument vector, unescaped. */ + args: string[]; + /** Human-readable, shell-escaped command line. */ + display: string; +} + /** A domain event relayed from the backend (tagged union on `type`). */ export type DomainEvent = | { type: "projectCreated"; projectId: string } diff --git a/frontend/src/features/first-run/FirstRunWizard.test.tsx b/frontend/src/features/first-run/FirstRunWizard.test.tsx index 893ec28..899e66b 100644 --- a/frontend/src/features/first-run/FirstRunWizard.test.tsx +++ b/frontend/src/features/first-run/FirstRunWizard.test.tsx @@ -425,6 +425,8 @@ describe("FirstRunWizard — several local OpenCode profiles (F36)", () => { baseURL: "http://localhost:8080/v1", port: 8080, servedModelName: "qwen3-coder-30b", + host: "127.0.0.1", + jinja: false, args: [], autoStart: false, stopPolicy: "stopOnAppExit", diff --git a/frontend/src/features/model-servers/ModelServersPanel.tsx b/frontend/src/features/model-servers/ModelServersPanel.tsx index 2c3b968..c24116b 100644 --- a/frontend/src/features/model-servers/ModelServersPanel.tsx +++ b/frontend/src/features/model-servers/ModelServersPanel.tsx @@ -1,24 +1,34 @@ /** - * `ModelServersPanel` (F35.2) — declare / edit / delete the local `llama.cpp` - * servers an OpenCode profile can bind to. Presentational: all I/O state comes - * from a {@link ModelServersViewModel} passed in (`useModelServers`), so the - * panel renders and is testable in isolation. + * `ModelServersPanel` (F35, wizard V2) — declare / edit / delete the local + * `llama.cpp` servers an OpenCode profile can bind to. Presentational: all I/O + * state comes from a {@link ModelServersViewModel} passed in (`useModelServers`), + * so the panel renders and is testable in isolation. * - * A single editable draft is shown at a time (add or edit); the list underneath - * offers Edit / Delete per server. The `model_server_in_use` rejection surfaces - * as the vm's error banner (a referenced server can't be removed). + * The editor is a plain-language wizard: a source segmented control (download + * from Hugging Face vs. a local `.gguf` file), a shared "model name in IdeA" + * field, the server settings, a collapsed advanced section for the raw + * llama.cpp knobs, and a read-only command preview built by the backend (never + * reconstructed here). */ -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; -import type { LocalModelServerConfig, StopPolicy } from "@/domain"; +import type { + LocalModelServerConfig, + ModelServerCommandPreview, + ModelSource, + StopPolicy, +} from "@/domain"; import { Button, IconButton, Input, Panel, cn } from "@/shared"; import type { ModelServersViewModel } from "./useModelServers"; import { + deriveServedModelName, emptyModelServer, parseArgs, + reservedFlagsIn, validateModelServer, type ModelServerErrors, + type ModelSourceKind, } from "./modelServer"; /** Small caption above a control. */ @@ -26,6 +36,11 @@ function Caption({ children }: { children: React.ReactNode }) { return {children}; } +/** Muted helper line under a control. */ +function Hint({ children }: { children: React.ReactNode }) { + return {children}; +} + const STOP_POLICIES: { value: StopPolicy; label: string }[] = [ { value: "stopOnAppExit", label: "Stop on app exit" }, { value: "keepAlive", label: "Keep alive" }, @@ -132,6 +147,7 @@ export function ModelServersPanel({ vm }: ModelServersPanelProps) { { vm.clearError(); @@ -145,16 +161,114 @@ export function ModelServersPanel({ vm }: ModelServersPanelProps) { ); } -/** The add/edit form for one server draft. */ +/** Segmented control choosing the model source (no jargon in the labels). */ +function SourceTabs({ + kind, + onSelect, +}: { + kind: ModelSourceKind; + onSelect: (kind: ModelSourceKind) => void; +}) { + const tab = (value: ModelSourceKind, label: string) => { + const active = kind === value; + return ( + + ); + }; + return ( +
+ {tab("huggingFace", "Télécharger depuis Hugging Face")} + {tab("localPath", "Fichier .gguf local")} +
+ ); +} + +/** The read-only, backend-built command preview block. */ +function CommandPreview({ + preview, + autoStart, +}: { + preview: ModelServerCommandPreview | null; + autoStart: boolean; +}) { + const [copied, setCopied] = useState(false); + const title = autoStart ? "Commande générée" : "Commande à lancer manuellement"; + + async function copy() { + if (!preview) return; + try { + await navigator.clipboard.writeText(preview.display); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch { + // Clipboard denied — nothing actionable, the text stays selectable. + } + } + + return ( +
+
+ {title} + +
+ {preview ? ( + + {preview.display} + + ) : ( + + Renseigne une source de modèle pour voir la commande. + + )} +
+ ); +} + +/** The add/edit wizard for one server draft. */ function ServerEditor({ draft, busy, + preview, onChange, onCancel, onSubmit, }: { draft: LocalModelServerConfig; busy: boolean; + preview: ( + config: LocalModelServerConfig, + ) => Promise; onChange: (next: LocalModelServerConfig) => void; onCancel: () => void; onSubmit: () => void; @@ -164,17 +278,80 @@ function ServerEditor({ const patch = (next: Partial) => onChange({ ...draft, ...next }); + // Sticky source tab: keep the chosen kind even when the field is momentarily + // blank (an empty field clears `modelSource`, which must not snap the tab). + const [sourceKind, setSourceKind] = useState( + draft.modelSource?.type ?? "huggingFace", + ); + + const hfRepo = + draft.modelSource?.type === "huggingFace" ? draft.modelSource.repo : ""; + const localPath = + draft.modelSource?.type === "localPath" ? draft.modelSource.path : ""; + + // Apply a new source and pre-fill the served name if the user hasn't set one. + function setSource(source: ModelSource | undefined) { + const next: Partial = { modelSource: source }; + if (draft.servedModelName.trim().length === 0) { + const derived = deriveServedModelName(source); + if (derived.length > 0) next.servedModelName = derived; + } + patch(next); + } + + function selectKind(kind: ModelSourceKind) { + if (kind === sourceKind) return; + setSourceKind(kind); + // Switching tabs starts the new source empty (⇒ cleared) rather than + // carrying the previous value into an incompatible field. + patch({ modelSource: undefined }); + } + + // Debounced backend preview. The backend is the sole authority on the argv. + const [cmd, setCmd] = useState(null); + const draftKey = JSON.stringify({ + modelSource: draft.modelSource, + port: draft.port, + host: draft.host, + gpuLayers: draft.gpuLayers, + contextSize: draft.contextSize, + jinja: draft.jinja, + binaryPath: draft.binaryPath, + args: draft.args, + }); + const latest = useRef(draft); + latest.current = draft; + useEffect(() => { + if (!draft.modelSource) { + setCmd(null); + return; + } + let cancelled = false; + const handle = setTimeout(() => { + void preview(latest.current).then((result) => { + if (!cancelled) setCmd(result); + }); + }, 250); + return () => { + cancelled = true; + clearTimeout(handle); + }; + // `draftKey` captures the argv-affecting fields; `preview` is stable. + }, [draftKey, preview]); // eslint-disable-line react-hooks/exhaustive-deps + + const reservedHits = reservedFlagsIn(draft.args); + return (
{draft.name.trim().length > 0 ? draft.name : "New server"} + {/* --- Source du modèle --------------------------------------------- */} +
+ Source du modèle + +
+ + {sourceKind === "huggingFace" ? ( + + ) : ( + + )} + + {/* --- Nom du modèle dans IdeA -------------------------------------- */} + + + {/* --- Serveur ------------------------------------------------------ */}
-