feat(model-server): source Hugging Face (-hf) + options structurées llama.cpp + migration store V2

Backend du support natif d'une source modèle Hugging Face en alternative au
chemin .gguf local pour le serveur llama.cpp intégré, et refonte des options.

- domaine : ModelSource {LocalPath|HuggingFace} + HfModelRef,
  LlamaCppOptions {host,gpu_layers,context_size,jinja}, invariant auto_start
  exigeant une source, validate_free_args (rejet des flags réservés).
- infra : build_argv partagé avec build_spawn_spec, émission -hf vs --model,
  ordre argv figé ; migration model-servers.json V1 -> V2.
- app-tauri : DTO V2 (modelSource, compat modelPath, conflit = INVALID),
  commande preview_model_server_command.

QA : cargo test ciblés verts (domain 7, application 8, infra model_server 4,
app-tauri dto 5). Échecs des suites complètes infra/app-tauri environnementaux
(sandbox bind/socket), hors périmètre.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-12 14:31:54 +02:00
parent bc96285fa5
commit 38aecf2cac
11 changed files with 820 additions and 102 deletions

View File

@ -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<PreviewModelServerCommandDto, ErrorDto> {
let config = model_server_config_domain(config, None)?;
let argv = infrastructure::LlamaCppRuntime::new()
.build_argv(&config)
.map_err(|err| ErrorDto {
code: "INVALID".to_owned(),
message: err.to_string(),
})?;
Ok(PreviewModelServerCommandDto {
display: display_command(&argv.command, &argv.args),
command: argv.command,
args: argv.args,
})
}
/// `delete_model_server` — delete a local model server config when unused.
///
/// # Errors
@ -924,6 +948,27 @@ fn model_server_command_error(err: AppError) -> ErrorDto {
}
}
fn display_command(command: &str, args: &[String]) -> String {
std::iter::once(command)
.chain(args.iter().map(String::as_str))
.map(shell_escape)
.collect::<Vec<_>>()
.join(" ")
}
fn shell_escape(value: &str) -> String {
if value.is_empty() {
return "''".to_owned();
}
if value
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '/' | '.' | '_' | '-' | ':' | '='))
{
return value.to_owned();
}
format!("'{}'", value.replace('\'', "'\\''"))
}
// ---------------------------------------------------------------------------
// Embedder profiles & engines (LOT C2 — §14.5.3)
// ---------------------------------------------------------------------------

View File

@ -913,8 +913,8 @@ impl From<FirstRunStateOutput> 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<StopPolicyDto> for StopPolicy {
}
}
/// Model source on the IPC wire.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "type")]
pub enum ModelSourceDto {
/// Local `.gguf` path.
LocalPath {
/// Absolute local path.
path: String,
},
/// Hugging Face `namespace/repo[:quant]` reference.
HuggingFace {
/// Repository reference.
repo: String,
},
}
impl From<ModelSource> for ModelSourceDto {
fn from(source: ModelSource) -> Self {
match source {
ModelSource::LocalPath { path } => Self::LocalPath { path: path.0 },
ModelSource::HuggingFace { repo } => Self::HuggingFace { repo: repo.0 },
}
}
}
impl ModelSourceDto {
fn into_domain(self) -> Result<ModelSource, ErrorDto> {
match self {
Self::LocalPath { path } => Ok(ModelSource::LocalPath {
path: ModelPath::new(path).map_err(invalid_domain_error)?,
}),
Self::HuggingFace { repo } => Ok(ModelSource::HuggingFace {
repo: HfModelRef::new(repo).map_err(invalid_domain_error)?,
}),
}
}
}
/// A local model-server config crossing the wire.
#[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<ModelSourceDto>,
/// Legacy V1 input alias for `modelSource:{type:"localPath",path}`.
#[serde(default, skip_serializing)]
pub model_path: Option<String>,
/// 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<String>,
/// llama.cpp host passed to `--host`.
#[serde(default = "default_llamacpp_host")]
pub host: String,
/// llama.cpp GPU layers passed to `-ngl`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gpu_layers: Option<u32>,
/// llama.cpp context size passed to `-c`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context_size: Option<u32>,
/// Whether to pass `--jinja`.
#[serde(default)]
pub jinja: bool,
/// Extra argv entries.
#[serde(default)]
pub args: Vec<String>,
@ -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<String>,
/// 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<LocalModelServerConfig, ErrorDto> {
config.into_domain(existing)
}
fn resolve_model_source(
model_source: Option<ModelSourceDto>,
model_path: Option<String>,
) -> Result<Option<ModelSource>, ErrorDto> {
let legacy_path = optional_non_empty(model_path);
match (model_source, legacy_path) {
(None, None) => Ok(None),
(None, Some(path)) => Ok(Some(ModelSource::LocalPath {
path: ModelPath::new(path).map_err(invalid_domain_error)?,
})),
(Some(source), None) => Ok(Some(source.into_domain()?)),
(Some(source), Some(path)) => {
let domain_source = source.into_domain()?;
match &domain_source {
ModelSource::LocalPath { path: source_path } if source_path.as_str() == path => {
Ok(Some(domain_source))
}
_ => Err(ErrorDto {
code: "INVALID".to_owned(),
message: "modelPath and modelSource differ".to_owned(),
}),
}
}
}
}
fn optional_non_empty(raw: Option<String>) -> Option<String> {
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() {

View File

@ -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,