Merge feature/model-server-hf-source-wizard-v2 into develop
Support natif d'une source modèle Hugging Face (-hf) en alternative au chemin .gguf local pour le serveur llama.cpp intégré, options structurées llama.cpp (host/gpu_layers/context_size/jinja), preview de commande, et refonte UX du wizard V2. Migration store model-servers.json V1 -> V2. QA : suites ciblées vertes (domain 7, application 8, infra model_server 4, app-tauri dto 5, Vitest 22, tsc/build OK). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -26,12 +26,14 @@ use application::{
|
|||||||
UnassignSkillFromAgentInput, UpdateAgentContextInput, UpdateAgentPermissionsInput,
|
UnassignSkillFromAgentInput, UpdateAgentContextInput, UpdateAgentPermissionsInput,
|
||||||
UpdateMemoryInput, UpdateProjectContextInput, UpdateProjectPermissionsInput, UpdateSkillInput,
|
UpdateMemoryInput, UpdateProjectContextInput, UpdateProjectPermissionsInput, UpdateSkillInput,
|
||||||
};
|
};
|
||||||
|
use domain::ports::ModelServerRuntime;
|
||||||
use domain::ports::PtyHandle;
|
use domain::ports::PtyHandle;
|
||||||
|
|
||||||
use crate::dto::{
|
use crate::dto::{
|
||||||
parse_agent_id, parse_close_terminal, parse_delete_profile, parse_layout_id, parse_memory_slug,
|
model_server_config_domain, parse_agent_id, parse_close_terminal, parse_delete_profile,
|
||||||
parse_node_id, parse_profile_id, parse_project_id, parse_session_id, parse_skill_id,
|
parse_layout_id, parse_memory_slug, parse_model_server_id, parse_node_id, parse_profile_id,
|
||||||
parse_task_id, parse_template_id, parse_ticket_id, AgentDriftListDto, AgentDto, AgentListDto,
|
parse_project_id, parse_session_id, parse_skill_id, parse_task_id, parse_template_id,
|
||||||
|
parse_ticket_id, save_model_server_input, AgentDriftListDto, AgentDto, AgentListDto,
|
||||||
AssignSkillRequestDto, AttachLiveAgentRequestDto, AttachLiveAgentResponseDto,
|
AssignSkillRequestDto, AttachLiveAgentRequestDto, AttachLiveAgentResponseDto,
|
||||||
BackgroundTaskDto, ChangeAgentProfileDto, ChangeAgentProfileRequestDto,
|
BackgroundTaskDto, ChangeAgentProfileDto, ChangeAgentProfileRequestDto,
|
||||||
CloneOpenCodeProfileFromSeedRequestDto, ConfigureProfilesRequestDto, ConversationDetailsDto,
|
CloneOpenCodeProfileFromSeedRequestDto, ConfigureProfilesRequestDto, ConversationDetailsDto,
|
||||||
@ -45,19 +47,19 @@ use crate::dto::{
|
|||||||
GraphCommitListDto, HealthRequestDto, HealthResponseDto, InspectConversationRequestDto,
|
GraphCommitListDto, HealthRequestDto, HealthResponseDto, InspectConversationRequestDto,
|
||||||
InterruptAgentRequestDto, LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto,
|
InterruptAgentRequestDto, LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto,
|
||||||
LiveAgentListDto, MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto,
|
LiveAgentListDto, MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto,
|
||||||
ModelServerConfigDto, ModelServerConfigListDto, OpenTerminalRequestDto, ProfileDto,
|
ModelServerConfigDto, ModelServerConfigListDto, OpenTerminalRequestDto,
|
||||||
ProfileListDto, ProjectDto, ProjectListDto, ProjectPermissionsDto, ProjectWorkStateDto,
|
PreviewModelServerCommandDto, ProfileDto, ProfileListDto, ProjectDto, ProjectListDto,
|
||||||
ReadAgentContextResponseDto, ReadConversationPageRequestDto, ReattachChatDto,
|
ProjectPermissionsDto, ProjectWorkStateDto, ReadAgentContextResponseDto,
|
||||||
ReattachResultDto, RecallMemoryRequestDto, RenameLayoutRequestDto, ReplyChunk,
|
ReadConversationPageRequestDto, ReattachChatDto, ReattachResultDto, RecallMemoryRequestDto,
|
||||||
ResizeTerminalRequestDto, ResolveAgentPermissionsRequestDto, ResumableAgentListDto,
|
RenameLayoutRequestDto, ReplyChunk, ResizeTerminalRequestDto,
|
||||||
SaveEmbedderProfileRequestDto, SaveModelServerRequestDto, SaveProfileRequestDto,
|
ResolveAgentPermissionsRequestDto, ResumableAgentListDto, SaveEmbedderProfileRequestDto,
|
||||||
SetActiveLayoutRequestDto, SetActiveLayoutResultDto, SkillDto, SkillListDto,
|
SaveModelServerRequestDto, SaveProfileRequestDto, SetActiveLayoutRequestDto,
|
||||||
StopLiveAgentRequestDto, StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto,
|
SetActiveLayoutResultDto, SkillDto, SkillListDto, StopLiveAgentRequestDto,
|
||||||
SyncResultDto, TemplateDto, TemplateListDto, TerminalClosedDto, TerminalSessionDto,
|
StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto,
|
||||||
TurnPageDto, UnassignSkillRequestDto, UpdateAgentContextRequestDto,
|
TemplateListDto, TerminalClosedDto, TerminalSessionDto, TurnPageDto, UnassignSkillRequestDto,
|
||||||
UpdateAgentPermissionsRequestDto, UpdateMemoryRequestDto, UpdateProjectContextRequestDto,
|
UpdateAgentContextRequestDto, UpdateAgentPermissionsRequestDto, UpdateMemoryRequestDto,
|
||||||
UpdateProjectPermissionsRequestDto, UpdateSkillRequestDto, UpdateTemplateRequestDto,
|
UpdateProjectContextRequestDto, UpdateProjectPermissionsRequestDto, UpdateSkillRequestDto,
|
||||||
WriteTerminalRequestDto, parse_model_server_id, save_model_server_input,
|
UpdateTemplateRequestDto, WriteTerminalRequestDto,
|
||||||
};
|
};
|
||||||
use crate::pty::{PtyBridge, PtyChunk};
|
use crate::pty::{PtyBridge, PtyChunk};
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
@ -900,6 +902,28 @@ pub async fn save_model_server(
|
|||||||
.map_err(ErrorDto::from)
|
.map_err(ErrorDto::from)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `preview_model_server_command` — build the llama.cpp argv without persisting.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns an [`ErrorDto`] when the DTO or runtime invocation is invalid.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn preview_model_server_command(
|
||||||
|
config: ModelServerConfigDto,
|
||||||
|
) -> Result<PreviewModelServerCommandDto, ErrorDto> {
|
||||||
|
let config = model_server_config_domain(config, None)?;
|
||||||
|
let argv = infrastructure::LlamaCppRuntime::new()
|
||||||
|
.build_argv(&config)
|
||||||
|
.map_err(|err| ErrorDto {
|
||||||
|
code: "INVALID".to_owned(),
|
||||||
|
message: err.to_string(),
|
||||||
|
})?;
|
||||||
|
Ok(PreviewModelServerCommandDto {
|
||||||
|
display: display_command(&argv.command, &argv.args),
|
||||||
|
command: argv.command,
|
||||||
|
args: argv.args,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// `delete_model_server` — delete a local model server config when unused.
|
/// `delete_model_server` — delete a local model server config when unused.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
@ -924,6 +948,27 @@ fn model_server_command_error(err: AppError) -> ErrorDto {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn display_command(command: &str, args: &[String]) -> String {
|
||||||
|
std::iter::once(command)
|
||||||
|
.chain(args.iter().map(String::as_str))
|
||||||
|
.map(shell_escape)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shell_escape(value: &str) -> String {
|
||||||
|
if value.is_empty() {
|
||||||
|
return "''".to_owned();
|
||||||
|
}
|
||||||
|
if value
|
||||||
|
.chars()
|
||||||
|
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '/' | '.' | '_' | '-' | ':' | '='))
|
||||||
|
{
|
||||||
|
return value.to_owned();
|
||||||
|
}
|
||||||
|
format!("'{}'", value.replace('\'', "'\\''"))
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Embedder profiles & engines (LOT C2 — §14.5.3)
|
// Embedder profiles & engines (LOT C2 — §14.5.3)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@ -913,8 +913,8 @@ impl From<FirstRunStateOutput> for FirstRunStateDto {
|
|||||||
|
|
||||||
use application::{ListModelServersOutput, SaveModelServerInput, SaveModelServerOutput};
|
use application::{ListModelServersOutput, SaveModelServerInput, SaveModelServerOutput};
|
||||||
use domain::model_server::{
|
use domain::model_server::{
|
||||||
ExecutablePath, LocalModelRef, LocalModelServerConfig, LocalModelServerKind, ModelPath,
|
ExecutablePath, HfModelRef, LlamaCppOptions, LocalModelRef, LocalModelServerConfig,
|
||||||
ModelServerEndpoint,
|
LocalModelServerKind, ModelPath, ModelServerEndpoint, ModelSource,
|
||||||
};
|
};
|
||||||
use domain::{LocalModelServerId, StopPolicy};
|
use domain::{LocalModelServerId, StopPolicy};
|
||||||
|
|
||||||
@ -974,6 +974,44 @@ impl From<StopPolicyDto> for StopPolicy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Model source on the IPC wire.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", tag = "type")]
|
||||||
|
pub enum ModelSourceDto {
|
||||||
|
/// Local `.gguf` path.
|
||||||
|
LocalPath {
|
||||||
|
/// Absolute local path.
|
||||||
|
path: String,
|
||||||
|
},
|
||||||
|
/// Hugging Face `namespace/repo[:quant]` reference.
|
||||||
|
HuggingFace {
|
||||||
|
/// Repository reference.
|
||||||
|
repo: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ModelSource> for ModelSourceDto {
|
||||||
|
fn from(source: ModelSource) -> Self {
|
||||||
|
match source {
|
||||||
|
ModelSource::LocalPath { path } => Self::LocalPath { path: path.0 },
|
||||||
|
ModelSource::HuggingFace { repo } => Self::HuggingFace { repo: repo.0 },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ModelSourceDto {
|
||||||
|
fn into_domain(self) -> Result<ModelSource, ErrorDto> {
|
||||||
|
match self {
|
||||||
|
Self::LocalPath { path } => Ok(ModelSource::LocalPath {
|
||||||
|
path: ModelPath::new(path).map_err(invalid_domain_error)?,
|
||||||
|
}),
|
||||||
|
Self::HuggingFace { repo } => Ok(ModelSource::HuggingFace {
|
||||||
|
repo: HfModelRef::new(repo).map_err(invalid_domain_error)?,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A local model-server config crossing the wire.
|
/// A local model-server config crossing the wire.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@ -989,14 +1027,29 @@ pub struct ModelServerConfigDto {
|
|||||||
pub base_url: String,
|
pub base_url: String,
|
||||||
/// TCP port.
|
/// TCP port.
|
||||||
pub port: u16,
|
pub port: u16,
|
||||||
/// Optional explicit `.gguf` model path.
|
/// Optional explicit model source.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub model_source: Option<ModelSourceDto>,
|
||||||
|
/// Legacy V1 input alias for `modelSource:{type:"localPath",path}`.
|
||||||
|
#[serde(default, skip_serializing)]
|
||||||
pub model_path: Option<String>,
|
pub model_path: Option<String>,
|
||||||
/// Served model name exposed to OpenCode.
|
/// Served model name exposed to OpenCode.
|
||||||
pub served_model_name: String,
|
pub served_model_name: String,
|
||||||
/// Optional explicit `llama-server` executable path.
|
/// Optional explicit `llama-server` executable path.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub binary_path: Option<String>,
|
pub binary_path: Option<String>,
|
||||||
|
/// llama.cpp host passed to `--host`.
|
||||||
|
#[serde(default = "default_llamacpp_host")]
|
||||||
|
pub host: String,
|
||||||
|
/// llama.cpp GPU layers passed to `-ngl`.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub gpu_layers: Option<u32>,
|
||||||
|
/// llama.cpp context size passed to `-c`.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub context_size: Option<u32>,
|
||||||
|
/// Whether to pass `--jinja`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub jinja: bool,
|
||||||
/// Extra argv entries.
|
/// Extra argv entries.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub args: Vec<String>,
|
pub args: Vec<String>,
|
||||||
@ -1016,12 +1069,14 @@ impl ModelServerConfigDto {
|
|||||||
name: config.name,
|
name: config.name,
|
||||||
base_url: config.endpoint.base_url,
|
base_url: config.endpoint.base_url,
|
||||||
port: config.endpoint.port,
|
port: config.endpoint.port,
|
||||||
model_path: config
|
model_source: config.model.source.map(Into::into),
|
||||||
.model
|
model_path: None,
|
||||||
.path
|
|
||||||
.map(|path| path.as_str().to_owned()),
|
|
||||||
served_model_name: config.model.served_name,
|
served_model_name: config.model.served_name,
|
||||||
binary_path: config.binary.map(|binary| binary.as_str().to_owned()),
|
binary_path: config.binary.map(|binary| binary.as_str().to_owned()),
|
||||||
|
host: config.options.host,
|
||||||
|
gpu_layers: config.options.gpu_layers,
|
||||||
|
context_size: config.options.context_size,
|
||||||
|
jinja: config.options.jinja,
|
||||||
args: config.args,
|
args: config.args,
|
||||||
auto_start: config.auto_start,
|
auto_start: config.auto_start,
|
||||||
stop_policy: config.stop_policy.into(),
|
stop_policy: config.stop_policy.into(),
|
||||||
@ -1040,26 +1095,21 @@ impl ModelServerConfigDto {
|
|||||||
let server_id = parse_model_server_id(&self.id)?;
|
let server_id = parse_model_server_id(&self.id)?;
|
||||||
let endpoint = ModelServerEndpoint::new(self.base_url.clone(), self.port)
|
let endpoint = ModelServerEndpoint::new(self.base_url.clone(), self.port)
|
||||||
.map_err(invalid_domain_error)?;
|
.map_err(invalid_domain_error)?;
|
||||||
let model_path = optional_non_empty(self.model_path)
|
let source = resolve_model_source(self.model_source, self.model_path)?;
|
||||||
.map(ModelPath::new)
|
|
||||||
.transpose()
|
|
||||||
.map_err(invalid_domain_error)?;
|
|
||||||
let model_id = existing
|
let model_id = existing
|
||||||
.filter(|config| config.id == server_id)
|
.filter(|config| config.id == server_id)
|
||||||
.map(|config| config.model.id.clone())
|
.map(|config| config.model.id.clone())
|
||||||
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
|
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
|
||||||
let label = non_empty_or_fallback(&self.served_model_name, &self.name);
|
let label = non_empty_or_fallback(&self.served_model_name, &self.name);
|
||||||
let model = LocalModelRef::new(
|
let model = LocalModelRef::new(model_id, label, source, self.served_model_name.clone())
|
||||||
model_id,
|
.map_err(invalid_domain_error)?;
|
||||||
label,
|
|
||||||
model_path,
|
|
||||||
self.served_model_name.clone(),
|
|
||||||
)
|
|
||||||
.map_err(invalid_domain_error)?;
|
|
||||||
let binary = optional_non_empty(self.binary_path)
|
let binary = optional_non_empty(self.binary_path)
|
||||||
.map(ExecutablePath::new)
|
.map(ExecutablePath::new)
|
||||||
.transpose()
|
.transpose()
|
||||||
.map_err(invalid_domain_error)?;
|
.map_err(invalid_domain_error)?;
|
||||||
|
let options =
|
||||||
|
LlamaCppOptions::new(self.host, self.gpu_layers, self.context_size, self.jinja)
|
||||||
|
.map_err(invalid_domain_error)?;
|
||||||
LocalModelServerConfig::new(
|
LocalModelServerConfig::new(
|
||||||
server_id,
|
server_id,
|
||||||
self.kind.into(),
|
self.kind.into(),
|
||||||
@ -1067,6 +1117,7 @@ impl ModelServerConfigDto {
|
|||||||
endpoint,
|
endpoint,
|
||||||
model,
|
model,
|
||||||
binary,
|
binary,
|
||||||
|
options,
|
||||||
self.args,
|
self.args,
|
||||||
self.auto_start,
|
self.auto_start,
|
||||||
self.stop_policy.into(),
|
self.stop_policy.into(),
|
||||||
@ -1075,6 +1126,18 @@ impl ModelServerConfigDto {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Response DTO for `preview_model_server_command`.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PreviewModelServerCommandDto {
|
||||||
|
/// Executable command.
|
||||||
|
pub command: String,
|
||||||
|
/// Arguments without shell parsing.
|
||||||
|
pub args: Vec<String>,
|
||||||
|
/// Human-readable escaped command line.
|
||||||
|
pub display: String,
|
||||||
|
}
|
||||||
|
|
||||||
/// List response for local model servers.
|
/// List response for local model servers.
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
#[serde(transparent)]
|
#[serde(transparent)]
|
||||||
@ -1132,11 +1195,52 @@ pub fn save_model_server_input(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Converts a model-server DTO to domain using the same path as save.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`ErrorDto`] if DTO-to-domain validation fails.
|
||||||
|
pub fn model_server_config_domain(
|
||||||
|
config: ModelServerConfigDto,
|
||||||
|
existing: Option<&LocalModelServerConfig>,
|
||||||
|
) -> Result<LocalModelServerConfig, ErrorDto> {
|
||||||
|
config.into_domain(existing)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_model_source(
|
||||||
|
model_source: Option<ModelSourceDto>,
|
||||||
|
model_path: Option<String>,
|
||||||
|
) -> Result<Option<ModelSource>, ErrorDto> {
|
||||||
|
let legacy_path = optional_non_empty(model_path);
|
||||||
|
match (model_source, legacy_path) {
|
||||||
|
(None, None) => Ok(None),
|
||||||
|
(None, Some(path)) => Ok(Some(ModelSource::LocalPath {
|
||||||
|
path: ModelPath::new(path).map_err(invalid_domain_error)?,
|
||||||
|
})),
|
||||||
|
(Some(source), None) => Ok(Some(source.into_domain()?)),
|
||||||
|
(Some(source), Some(path)) => {
|
||||||
|
let domain_source = source.into_domain()?;
|
||||||
|
match &domain_source {
|
||||||
|
ModelSource::LocalPath { path: source_path } if source_path.as_str() == path => {
|
||||||
|
Ok(Some(domain_source))
|
||||||
|
}
|
||||||
|
_ => Err(ErrorDto {
|
||||||
|
code: "INVALID".to_owned(),
|
||||||
|
message: "modelPath and modelSource differ".to_owned(),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn optional_non_empty(raw: Option<String>) -> Option<String> {
|
fn optional_non_empty(raw: Option<String>) -> Option<String> {
|
||||||
raw.map(|value| value.trim().to_owned())
|
raw.map(|value| value.trim().to_owned())
|
||||||
.filter(|value| !value.is_empty())
|
.filter(|value| !value.is_empty())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_llamacpp_host() -> String {
|
||||||
|
"127.0.0.1".to_owned()
|
||||||
|
}
|
||||||
|
|
||||||
fn non_empty_or_fallback(primary: &str, fallback: &str) -> String {
|
fn non_empty_or_fallback(primary: &str, fallback: &str) -> String {
|
||||||
let primary = primary.trim();
|
let primary = primary.trim();
|
||||||
if primary.is_empty() {
|
if primary.is_empty() {
|
||||||
|
|||||||
@ -164,6 +164,7 @@ pub fn run() {
|
|||||||
commands::configure_profiles,
|
commands::configure_profiles,
|
||||||
commands::list_model_servers,
|
commands::list_model_servers,
|
||||||
commands::save_model_server,
|
commands::save_model_server,
|
||||||
|
commands::preview_model_server_command,
|
||||||
commands::delete_model_server,
|
commands::delete_model_server,
|
||||||
commands::list_embedder_profiles,
|
commands::list_embedder_profiles,
|
||||||
commands::save_embedder_profile,
|
commands::save_embedder_profile,
|
||||||
|
|||||||
@ -1,12 +1,12 @@
|
|||||||
//! DTO contract tests for local model-server IPC.
|
//! DTO contract tests for local model-server IPC.
|
||||||
|
|
||||||
use app_tauri_lib::dto::{
|
use app_tauri_lib::dto::{
|
||||||
ModelServerConfigDto, ModelServerKindDto, SaveModelServerRequestDto, StopPolicyDto,
|
save_model_server_input, ModelServerConfigDto, ModelServerKindDto, ModelSourceDto,
|
||||||
save_model_server_input,
|
SaveModelServerRequestDto, StopPolicyDto,
|
||||||
};
|
};
|
||||||
use domain::model_server::{
|
use domain::model_server::{
|
||||||
ExecutablePath, LocalModelRef, LocalModelServerConfig, LocalModelServerKind, ModelPath,
|
ExecutablePath, LlamaCppOptions, LocalModelRef, LocalModelServerConfig, LocalModelServerKind,
|
||||||
ModelServerEndpoint,
|
ModelPath, ModelServerEndpoint, ModelSource,
|
||||||
};
|
};
|
||||||
use domain::{LocalModelServerId, StopPolicy};
|
use domain::{LocalModelServerId, StopPolicy};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
@ -25,12 +25,15 @@ fn domain_config(id: LocalModelServerId, model_id: &str) -> LocalModelServerConf
|
|||||||
LocalModelRef::new(
|
LocalModelRef::new(
|
||||||
model_id,
|
model_id,
|
||||||
"Qwen",
|
"Qwen",
|
||||||
Some(ModelPath::new("/models/qwen.gguf").unwrap()),
|
Some(ModelSource::LocalPath {
|
||||||
|
path: ModelPath::new("/models/qwen.gguf").unwrap(),
|
||||||
|
}),
|
||||||
"qwen3-coder",
|
"qwen3-coder",
|
||||||
)
|
)
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
Some(ExecutablePath::new("/usr/bin/llama-server").unwrap()),
|
Some(ExecutablePath::new("/usr/bin/llama-server").unwrap()),
|
||||||
vec!["--ctx-size".to_owned(), "4096".to_owned()],
|
LlamaCppOptions::new("127.0.0.1", Some(35), Some(4096), true).unwrap(),
|
||||||
|
vec!["--threads".to_owned(), "8".to_owned()],
|
||||||
true,
|
true,
|
||||||
StopPolicy::StopOnAppExit,
|
StopPolicy::StopOnAppExit,
|
||||||
)
|
)
|
||||||
@ -46,12 +49,21 @@ fn model_server_dto_serialises_flat_camelcase_wire_shape() {
|
|||||||
assert_eq!(value["id"], sid(35).to_string());
|
assert_eq!(value["id"], sid(35).to_string());
|
||||||
assert_eq!(value["kind"], "llamaCpp");
|
assert_eq!(value["kind"], "llamaCpp");
|
||||||
assert_eq!(value["baseURL"], "http://localhost:8080/v1");
|
assert_eq!(value["baseURL"], "http://localhost:8080/v1");
|
||||||
assert_eq!(value["modelPath"], "/models/qwen.gguf");
|
assert_eq!(value["modelSource"]["type"], "localPath");
|
||||||
|
assert_eq!(value["modelSource"]["path"], "/models/qwen.gguf");
|
||||||
|
assert!(value.get("modelPath").is_none());
|
||||||
assert_eq!(value["servedModelName"], "qwen3-coder");
|
assert_eq!(value["servedModelName"], "qwen3-coder");
|
||||||
assert_eq!(value["binaryPath"], "/usr/bin/llama-server");
|
assert_eq!(value["binaryPath"], "/usr/bin/llama-server");
|
||||||
|
assert_eq!(value["host"], "127.0.0.1");
|
||||||
|
assert_eq!(value["gpuLayers"], 35);
|
||||||
|
assert_eq!(value["contextSize"], 4096);
|
||||||
|
assert_eq!(value["jinja"], true);
|
||||||
assert_eq!(value["stopPolicy"], "stopOnAppExit");
|
assert_eq!(value["stopPolicy"], "stopOnAppExit");
|
||||||
assert!(value.get("model").is_none(), "domain model must not leak");
|
assert!(value.get("model").is_none(), "domain model must not leak");
|
||||||
assert!(value.get("endpoint").is_none(), "domain endpoint must not leak");
|
assert!(
|
||||||
|
value.get("endpoint").is_none(),
|
||||||
|
"domain endpoint must not leak"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -65,7 +77,9 @@ fn model_server_dto_deserialises_flat_wire_shape_and_generates_model_id() {
|
|||||||
"modelPath": "/models/qwen.gguf",
|
"modelPath": "/models/qwen.gguf",
|
||||||
"servedModelName": "qwen3-coder",
|
"servedModelName": "qwen3-coder",
|
||||||
"binaryPath": "/usr/bin/llama-server",
|
"binaryPath": "/usr/bin/llama-server",
|
||||||
"args": ["--ctx-size", "4096"],
|
"gpuLayers": 35,
|
||||||
|
"contextSize": 4096,
|
||||||
|
"args": ["--threads", "8"],
|
||||||
"autoStart": true,
|
"autoStart": true,
|
||||||
"stopPolicy": "stopOnAppExit"
|
"stopPolicy": "stopOnAppExit"
|
||||||
});
|
});
|
||||||
@ -79,9 +93,15 @@ fn model_server_dto_deserialises_flat_wire_shape_and_generates_model_id() {
|
|||||||
assert_eq!(input.config.kind, LocalModelServerKind::LlamaCpp);
|
assert_eq!(input.config.kind, LocalModelServerKind::LlamaCpp);
|
||||||
assert_eq!(input.config.endpoint.base_url, "http://localhost:8081/v1");
|
assert_eq!(input.config.endpoint.base_url, "http://localhost:8081/v1");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
input.config.model.path.unwrap().as_str(),
|
input.config.model.source,
|
||||||
"/models/qwen.gguf"
|
Some(ModelSource::LocalPath {
|
||||||
|
path: ModelPath::new("/models/qwen.gguf").unwrap()
|
||||||
|
})
|
||||||
);
|
);
|
||||||
|
assert_eq!(input.config.options.gpu_layers, Some(35));
|
||||||
|
assert_eq!(input.config.options.context_size, Some(4096));
|
||||||
|
assert_eq!(input.config.options.host, "127.0.0.1");
|
||||||
|
assert!(!input.config.options.jinja);
|
||||||
assert_eq!(input.config.model.served_name, "qwen3-coder");
|
assert_eq!(input.config.model.served_name, "qwen3-coder");
|
||||||
assert_eq!(input.config.model.label, "qwen3-coder");
|
assert_eq!(input.config.model.label, "qwen3-coder");
|
||||||
assert!(!input.config.model.id.is_empty());
|
assert!(!input.config.model.id.is_empty());
|
||||||
@ -96,9 +116,16 @@ fn model_server_dto_preserves_existing_internal_model_id_on_upsert() {
|
|||||||
name: "Updated Qwen".to_owned(),
|
name: "Updated Qwen".to_owned(),
|
||||||
base_url: "http://localhost:8082/v1".to_owned(),
|
base_url: "http://localhost:8082/v1".to_owned(),
|
||||||
port: 8082,
|
port: 8082,
|
||||||
model_path: Some("/models/qwen-updated.gguf".to_owned()),
|
model_source: Some(ModelSourceDto::LocalPath {
|
||||||
|
path: "/models/qwen-updated.gguf".to_owned(),
|
||||||
|
}),
|
||||||
|
model_path: None,
|
||||||
served_model_name: "qwen3-coder-updated".to_owned(),
|
served_model_name: "qwen3-coder-updated".to_owned(),
|
||||||
binary_path: Some("/usr/bin/llama-server".to_owned()),
|
binary_path: Some("/usr/bin/llama-server".to_owned()),
|
||||||
|
host: "127.0.0.1".to_owned(),
|
||||||
|
gpu_layers: None,
|
||||||
|
context_size: None,
|
||||||
|
jinja: false,
|
||||||
args: Vec::new(),
|
args: Vec::new(),
|
||||||
auto_start: true,
|
auto_start: true,
|
||||||
stop_policy: StopPolicyDto::StopOnAppExit,
|
stop_policy: StopPolicyDto::StopOnAppExit,
|
||||||
@ -109,3 +136,66 @@ fn model_server_dto_preserves_existing_internal_model_id_on_upsert() {
|
|||||||
assert_eq!(config.model.id, "stable-internal-id");
|
assert_eq!(config.model.id, "stable-internal-id");
|
||||||
assert_eq!(config.model.served_name, "qwen3-coder-updated");
|
assert_eq!(config.model.served_name, "qwen3-coder-updated");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn model_server_dto_roundtrips_v2_huggingface_source() {
|
||||||
|
let raw = json!({
|
||||||
|
"id": sid(38).to_string(),
|
||||||
|
"kind": "llamaCpp",
|
||||||
|
"name": "HF Qwen",
|
||||||
|
"baseURL": "http://localhost:8083",
|
||||||
|
"port": 8083,
|
||||||
|
"modelSource": { "type": "huggingFace", "repo": "Qwen/Qwen3-Coder:Q4_K_M" },
|
||||||
|
"servedModelName": "qwen3-coder",
|
||||||
|
"binaryPath": "/usr/bin/llama-server",
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
"jinja": false,
|
||||||
|
"args": [],
|
||||||
|
"autoStart": true,
|
||||||
|
"stopPolicy": "stopOnAppExit"
|
||||||
|
});
|
||||||
|
|
||||||
|
let request = SaveModelServerRequestDto {
|
||||||
|
config: serde_json::from_value(raw).unwrap(),
|
||||||
|
};
|
||||||
|
let input = save_model_server_input(request, None).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
input.config.model.source,
|
||||||
|
Some(ModelSource::HuggingFace {
|
||||||
|
repo: domain::model_server::HfModelRef::new("Qwen/Qwen3-Coder:Q4_K_M").unwrap()
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
let value = serde_json::to_value(ModelServerConfigDto::from_domain(input.config)).unwrap();
|
||||||
|
assert_eq!(value["modelSource"]["type"], "huggingFace");
|
||||||
|
assert_eq!(value["modelSource"]["repo"], "Qwen/Qwen3-Coder:Q4_K_M");
|
||||||
|
assert!(value.get("modelPath").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn model_server_dto_rejects_conflicting_model_path_alias() {
|
||||||
|
let raw = json!({
|
||||||
|
"id": sid(39).to_string(),
|
||||||
|
"kind": "llamaCpp",
|
||||||
|
"name": "Local Qwen",
|
||||||
|
"baseURL": "http://localhost:8084",
|
||||||
|
"port": 8084,
|
||||||
|
"modelSource": { "type": "localPath", "path": "/models/a.gguf" },
|
||||||
|
"modelPath": "/models/b.gguf",
|
||||||
|
"servedModelName": "qwen3-coder",
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
"jinja": false,
|
||||||
|
"args": [],
|
||||||
|
"autoStart": true,
|
||||||
|
"stopPolicy": "stopOnAppExit"
|
||||||
|
});
|
||||||
|
|
||||||
|
let request = SaveModelServerRequestDto {
|
||||||
|
config: serde_json::from_value(raw).unwrap(),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
save_model_server_input(request, None).unwrap_err().code,
|
||||||
|
"INVALID"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -7,6 +7,7 @@ use std::time::Duration;
|
|||||||
use domain::events::DomainEvent;
|
use domain::events::DomainEvent;
|
||||||
use domain::model_server::{
|
use domain::model_server::{
|
||||||
LocalModelServerConfig, ModelServerLifecycleStatus, ModelServerReady, ModelServerStatus,
|
LocalModelServerConfig, ModelServerLifecycleStatus, ModelServerReady, ModelServerStatus,
|
||||||
|
ModelSource,
|
||||||
};
|
};
|
||||||
use domain::ports::{
|
use domain::ports::{
|
||||||
EventBus, FileSystem, ManagedProcess, ManagedProcessHandle, ModelServerError, ModelServerProbe,
|
EventBus, FileSystem, ManagedProcess, ManagedProcessHandle, ModelServerError, ModelServerProbe,
|
||||||
@ -332,11 +333,14 @@ impl EnsureLocalModelServer {
|
|||||||
&self,
|
&self,
|
||||||
config: &LocalModelServerConfig,
|
config: &LocalModelServerConfig,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
let Some(path) = config.model.path.as_ref() else {
|
let Some(ModelSource::LocalPath { path }) = config.model.source.as_ref() else {
|
||||||
let err = ModelServerError::PathNotAccessible("model.path missing".to_owned());
|
return Ok(());
|
||||||
|
};
|
||||||
|
if path.as_str().is_empty() {
|
||||||
|
let err = ModelServerError::PathNotAccessible("model.source.path missing".to_owned());
|
||||||
self.publish_failure(config.id, &err);
|
self.publish_failure(config.id, &err);
|
||||||
return Err(err.into());
|
return Err(err.into());
|
||||||
};
|
}
|
||||||
match self
|
match self
|
||||||
.fs
|
.fs
|
||||||
.exists(&RemotePath::new(path.as_str().to_owned()))
|
.exists(&RemotePath::new(path.as_str().to_owned()))
|
||||||
|
|||||||
@ -12,13 +12,13 @@ use application::{
|
|||||||
};
|
};
|
||||||
use domain::events::DomainEvent;
|
use domain::events::DomainEvent;
|
||||||
use domain::model_server::{
|
use domain::model_server::{
|
||||||
ExecutablePath, LocalModelRef, LocalModelServerConfig, LocalModelServerKind, ModelPath,
|
ExecutablePath, LlamaCppOptions, LocalModelRef, LocalModelServerConfig, LocalModelServerKind,
|
||||||
ModelServerEndpoint, ModelServerStatus, StopPolicy,
|
ModelPath, ModelServerEndpoint, ModelServerStatus, ModelSource, StopPolicy,
|
||||||
};
|
};
|
||||||
use domain::ports::{
|
use domain::ports::{
|
||||||
DirEntry, EventBus, EventStream, FileSystem, FsError, ManagedProcess, ManagedProcessHandle,
|
DirEntry, EventBus, EventStream, FileSystem, FsError, ManagedProcess, ManagedProcessHandle,
|
||||||
ModelServerError, ModelServerProbe, ModelServerRegistry, ModelServerRuntime, ProcessStatus,
|
ModelServerArgv, ModelServerError, ModelServerProbe, ModelServerRegistry, ModelServerRuntime,
|
||||||
ProfileStore, RemotePath, SpawnSpec, StoreError,
|
ProcessStatus, ProfileStore, RemotePath, SpawnSpec, StoreError,
|
||||||
};
|
};
|
||||||
use domain::profile::{AgentProfile, ContextInjection, OpenCodeConfig, StructuredAdapter};
|
use domain::profile::{AgentProfile, ContextInjection, OpenCodeConfig, StructuredAdapter};
|
||||||
use domain::{LocalModelServerId, ProfileId, ProjectPath};
|
use domain::{LocalModelServerId, ProfileId, ProjectPath};
|
||||||
@ -41,11 +41,14 @@ fn config(
|
|||||||
LocalModelRef::new(
|
LocalModelRef::new(
|
||||||
"qwen",
|
"qwen",
|
||||||
"Qwen",
|
"Qwen",
|
||||||
Some(ModelPath::new(path).unwrap()),
|
Some(ModelSource::LocalPath {
|
||||||
|
path: ModelPath::new(path).unwrap(),
|
||||||
|
}),
|
||||||
"qwen3-coder-30b",
|
"qwen3-coder-30b",
|
||||||
)
|
)
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
Some(ExecutablePath::new("llama-server").unwrap()),
|
Some(ExecutablePath::new("llama-server").unwrap()),
|
||||||
|
LlamaCppOptions::default(),
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
auto_start,
|
auto_start,
|
||||||
StopPolicy::StopOnAppExit,
|
StopPolicy::StopOnAppExit,
|
||||||
@ -192,11 +195,16 @@ impl ManagedProcess for FakeProcess {
|
|||||||
struct FakeRuntime;
|
struct FakeRuntime;
|
||||||
|
|
||||||
impl ModelServerRuntime for FakeRuntime {
|
impl ModelServerRuntime for FakeRuntime {
|
||||||
fn build_spawn_spec(
|
fn build_argv(
|
||||||
&self,
|
&self,
|
||||||
config: &LocalModelServerConfig,
|
config: &LocalModelServerConfig,
|
||||||
) -> Result<SpawnSpec, ModelServerError> {
|
) -> Result<ModelServerArgv, ModelServerError> {
|
||||||
Ok(SpawnSpec {
|
let Some(ModelSource::LocalPath { path }) = config.model.source.as_ref() else {
|
||||||
|
return Err(ModelServerError::PathNotAccessible(
|
||||||
|
"model.source missing".to_owned(),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
Ok(ModelServerArgv {
|
||||||
command: config
|
command: config
|
||||||
.binary
|
.binary
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@ -204,10 +212,23 @@ impl ModelServerRuntime for FakeRuntime {
|
|||||||
.unwrap_or_else(|| "llama-server".to_owned()),
|
.unwrap_or_else(|| "llama-server".to_owned()),
|
||||||
args: vec![
|
args: vec![
|
||||||
"--model".to_owned(),
|
"--model".to_owned(),
|
||||||
config.model.path.as_ref().unwrap().as_str().to_owned(),
|
path.as_str().to_owned(),
|
||||||
"--port".to_owned(),
|
"--port".to_owned(),
|
||||||
config.endpoint.port.to_string(),
|
config.endpoint.port.to_string(),
|
||||||
|
"--host".to_owned(),
|
||||||
|
config.options.host.clone(),
|
||||||
],
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_spawn_spec(
|
||||||
|
&self,
|
||||||
|
config: &LocalModelServerConfig,
|
||||||
|
) -> Result<SpawnSpec, ModelServerError> {
|
||||||
|
let argv = self.build_argv(config)?;
|
||||||
|
Ok(SpawnSpec {
|
||||||
|
command: argv.command,
|
||||||
|
args: argv.args,
|
||||||
cwd: ProjectPath::new("/").unwrap(),
|
cwd: ProjectPath::new("/").unwrap(),
|
||||||
env: Vec::new(),
|
env: Vec::new(),
|
||||||
context_plan: None,
|
context_plan: None,
|
||||||
@ -341,7 +362,14 @@ async fn absent_auto_start_spawns_and_waits_until_ready() {
|
|||||||
assert_eq!(spawns[0].command, "llama-server");
|
assert_eq!(spawns[0].command, "llama-server");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
spawns[0].args,
|
spawns[0].args,
|
||||||
vec!["--model", "/models/qwen.gguf", "--port", "8081"]
|
vec![
|
||||||
|
"--model",
|
||||||
|
"/models/qwen.gguf",
|
||||||
|
"--port",
|
||||||
|
"8081",
|
||||||
|
"--host",
|
||||||
|
"127.0.0.1"
|
||||||
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -155,9 +155,9 @@ pub use memory_harvest::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
pub use model_server::{
|
pub use model_server::{
|
||||||
ExecutablePath, LocalModelRef, LocalModelServerConfig, LocalModelServerKind, ModelPath,
|
validate_free_args, ExecutablePath, HfModelRef, LlamaCppOptions, LocalModelRef,
|
||||||
ModelServerEndpoint, ModelServerLifecycleStatus, ModelServerReady, ModelServerStatus,
|
LocalModelServerConfig, LocalModelServerKind, ModelPath, ModelServerEndpoint,
|
||||||
StopPolicy,
|
ModelServerLifecycleStatus, ModelServerReady, ModelServerStatus, ModelSource, StopPolicy,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub use remote::{RemoteKind, RemoteRef, SshAuth};
|
pub use remote::{RemoteKind, RemoteRef, SshAuth};
|
||||||
|
|||||||
@ -116,6 +116,81 @@ impl ModelPath {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Explicit source of the served model.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", tag = "type")]
|
||||||
|
pub enum ModelSource {
|
||||||
|
/// Local `.gguf` file path.
|
||||||
|
LocalPath {
|
||||||
|
/// Absolute local path.
|
||||||
|
path: ModelPath,
|
||||||
|
},
|
||||||
|
/// Hugging Face llama.cpp `-hf` reference.
|
||||||
|
HuggingFace {
|
||||||
|
/// `namespace/repo` or `namespace/repo:quant`.
|
||||||
|
repo: HfModelRef,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hugging Face model reference accepted by `llama-server -hf`.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(transparent)]
|
||||||
|
pub struct HfModelRef(pub String);
|
||||||
|
|
||||||
|
impl HfModelRef {
|
||||||
|
/// Builds a validated Hugging Face model reference.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns [`DomainError`] if the reference is blank, URL-like, contains
|
||||||
|
/// spaces, or does not match `namespace/repo[:quant]`.
|
||||||
|
pub fn new(repo: impl Into<String>) -> Result<Self, DomainError> {
|
||||||
|
let repo = repo.into();
|
||||||
|
crate::validation::non_empty(&repo, "modelServer.model.source.repo")?;
|
||||||
|
if repo.chars().any(char::is_whitespace) {
|
||||||
|
return Err(DomainError::Invariant(
|
||||||
|
"modelServer.model.source.repo must not contain spaces".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if repo.starts_with("http://") || repo.starts_with("https://") {
|
||||||
|
return Err(DomainError::Invariant(
|
||||||
|
"modelServer.model.source.repo must not be an URL in V1".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if repo.matches(':').count() > 1 {
|
||||||
|
return Err(DomainError::Invariant(
|
||||||
|
"modelServer.model.source.repo must contain at most one ':'".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let (base, quant) = repo
|
||||||
|
.split_once(':')
|
||||||
|
.map_or((repo.as_str(), None), |(base, quant)| (base, Some(quant)));
|
||||||
|
let Some((namespace, name)) = base.split_once('/') else {
|
||||||
|
return Err(DomainError::Invariant(
|
||||||
|
"modelServer.model.source.repo must be namespace/repo or namespace/repo:quant"
|
||||||
|
.to_owned(),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
if namespace.is_empty() || name.is_empty() || base.matches('/').count() != 1 {
|
||||||
|
return Err(DomainError::Invariant(
|
||||||
|
"modelServer.model.source.repo must be namespace/repo or namespace/repo:quant"
|
||||||
|
.to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if quant.is_some_and(str::is_empty) {
|
||||||
|
return Err(DomainError::Invariant(
|
||||||
|
"modelServer.model.source.repo quant must not be empty".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(Self(repo))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the reference as a string slice.
|
||||||
|
#[must_use]
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Absolute local path to an executable.
|
/// Absolute local path to an executable.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(transparent)]
|
#[serde(transparent)]
|
||||||
@ -159,9 +234,9 @@ pub struct LocalModelRef {
|
|||||||
pub id: String,
|
pub id: String,
|
||||||
/// Human display label.
|
/// Human display label.
|
||||||
pub label: String,
|
pub label: String,
|
||||||
/// Explicit absolute `.gguf` path. Required for auto-start.
|
/// Explicit source. Required for auto-start.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub path: Option<ModelPath>,
|
pub source: Option<ModelSource>,
|
||||||
/// Name exposed by the server and used in `OpenCodeConfig.model`.
|
/// Name exposed by the server and used in `OpenCodeConfig.model`.
|
||||||
pub served_name: String,
|
pub served_name: String,
|
||||||
}
|
}
|
||||||
@ -174,7 +249,7 @@ impl LocalModelRef {
|
|||||||
pub fn new(
|
pub fn new(
|
||||||
id: impl Into<String>,
|
id: impl Into<String>,
|
||||||
label: impl Into<String>,
|
label: impl Into<String>,
|
||||||
path: Option<ModelPath>,
|
source: Option<ModelSource>,
|
||||||
served_name: impl Into<String>,
|
served_name: impl Into<String>,
|
||||||
) -> Result<Self, DomainError> {
|
) -> Result<Self, DomainError> {
|
||||||
let id = id.into();
|
let id = id.into();
|
||||||
@ -186,12 +261,121 @@ impl LocalModelRef {
|
|||||||
Ok(Self {
|
Ok(Self {
|
||||||
id,
|
id,
|
||||||
label,
|
label,
|
||||||
path,
|
source,
|
||||||
served_name,
|
served_name,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Structured llama.cpp server options owned by IdeA.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct LlamaCppOptions {
|
||||||
|
/// Host passed to `--host`.
|
||||||
|
pub host: String,
|
||||||
|
/// GPU layers passed to `-ngl`.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub gpu_layers: Option<u32>,
|
||||||
|
/// Context size passed to `-c`.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub context_size: Option<u32>,
|
||||||
|
/// Enables llama.cpp Jinja templates.
|
||||||
|
pub jinja: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for LlamaCppOptions {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
host: "127.0.0.1".to_owned(),
|
||||||
|
gpu_layers: None,
|
||||||
|
context_size: None,
|
||||||
|
jinja: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LlamaCppOptions {
|
||||||
|
/// Builds validated llama.cpp options.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns [`DomainError`] if host is blank/contains spaces or numeric
|
||||||
|
/// options are zero.
|
||||||
|
pub fn new(
|
||||||
|
host: impl Into<String>,
|
||||||
|
gpu_layers: Option<u32>,
|
||||||
|
context_size: Option<u32>,
|
||||||
|
jinja: bool,
|
||||||
|
) -> Result<Self, DomainError> {
|
||||||
|
let options = Self {
|
||||||
|
host: host.into(),
|
||||||
|
gpu_layers,
|
||||||
|
context_size,
|
||||||
|
jinja,
|
||||||
|
};
|
||||||
|
options.validate()?;
|
||||||
|
Ok(options)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates option invariants.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns [`DomainError`] on invalid options.
|
||||||
|
pub fn validate(&self) -> Result<(), DomainError> {
|
||||||
|
crate::validation::non_empty(&self.host, "modelServer.options.host")?;
|
||||||
|
if self.host.chars().any(char::is_whitespace) {
|
||||||
|
return Err(DomainError::Invariant(
|
||||||
|
"modelServer.options.host must not contain spaces".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if self.gpu_layers == Some(0) {
|
||||||
|
return Err(DomainError::Invariant(
|
||||||
|
"modelServer.options.gpuLayers must be > 0".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if self.context_size == Some(0) {
|
||||||
|
return Err(DomainError::Invariant(
|
||||||
|
"modelServer.options.contextSize must be > 0".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates free-form runtime arguments do not duplicate structured options.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns [`DomainError`] if an argument is a reserved exact flag or
|
||||||
|
/// `--flag=value` form.
|
||||||
|
pub fn validate_free_args(args: &[String]) -> Result<(), DomainError> {
|
||||||
|
const RESERVED: &[&str] = &[
|
||||||
|
"--model",
|
||||||
|
"-m",
|
||||||
|
"-hf",
|
||||||
|
"--port",
|
||||||
|
"--host",
|
||||||
|
"-ngl",
|
||||||
|
"--gpu-layers",
|
||||||
|
"-c",
|
||||||
|
"--ctx-size",
|
||||||
|
"--jinja",
|
||||||
|
];
|
||||||
|
for arg in args {
|
||||||
|
if RESERVED.contains(&arg.as_str()) {
|
||||||
|
return Err(DomainError::Invariant(format!(
|
||||||
|
"modelServer.args contains reserved flag {arg}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if let Some((flag, _)) = arg.split_once('=') {
|
||||||
|
if RESERVED.contains(&flag) {
|
||||||
|
return Err(DomainError::Invariant(format!(
|
||||||
|
"modelServer.args contains reserved flag {flag}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Stop policy for a managed local model server.
|
/// Stop policy for a managed local model server.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@ -221,6 +405,8 @@ pub struct LocalModelServerConfig {
|
|||||||
/// Optional executable path or bare command name.
|
/// Optional executable path or bare command name.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub binary: Option<ExecutablePath>,
|
pub binary: Option<ExecutablePath>,
|
||||||
|
/// Structured llama.cpp options.
|
||||||
|
pub options: LlamaCppOptions,
|
||||||
/// Extra argv passed to the runtime.
|
/// Extra argv passed to the runtime.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub args: Vec<String>,
|
pub args: Vec<String>,
|
||||||
@ -249,21 +435,24 @@ impl LocalModelServerConfig {
|
|||||||
endpoint: ModelServerEndpoint,
|
endpoint: ModelServerEndpoint,
|
||||||
model: LocalModelRef,
|
model: LocalModelRef,
|
||||||
binary: Option<ExecutablePath>,
|
binary: Option<ExecutablePath>,
|
||||||
|
options: LlamaCppOptions,
|
||||||
args: Vec<String>,
|
args: Vec<String>,
|
||||||
auto_start: bool,
|
auto_start: bool,
|
||||||
stop_policy: StopPolicy,
|
stop_policy: StopPolicy,
|
||||||
) -> Result<Self, DomainError> {
|
) -> Result<Self, DomainError> {
|
||||||
let name = name.into();
|
let name = name.into();
|
||||||
crate::validation::non_empty(&name, "modelServer.name")?;
|
crate::validation::non_empty(&name, "modelServer.name")?;
|
||||||
|
options.validate()?;
|
||||||
|
validate_free_args(&args)?;
|
||||||
if auto_start {
|
if auto_start {
|
||||||
if kind != LocalModelServerKind::LlamaCpp {
|
if kind != LocalModelServerKind::LlamaCpp {
|
||||||
return Err(DomainError::Invariant(
|
return Err(DomainError::Invariant(
|
||||||
"modelServer.autoStart requires kind=LlamaCpp".to_owned(),
|
"modelServer.autoStart requires kind=LlamaCpp".to_owned(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if model.path.is_none() {
|
if model.source.is_none() {
|
||||||
return Err(DomainError::Invariant(
|
return Err(DomainError::Invariant(
|
||||||
"modelServer.autoStart requires model.path".to_owned(),
|
"modelServer.autoStart requires model.source".to_owned(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -274,6 +463,7 @@ impl LocalModelServerConfig {
|
|||||||
endpoint,
|
endpoint,
|
||||||
model,
|
model,
|
||||||
binary,
|
binary,
|
||||||
|
options,
|
||||||
args,
|
args,
|
||||||
auto_start,
|
auto_start,
|
||||||
stop_policy,
|
stop_policy,
|
||||||
@ -342,13 +532,12 @@ mod tests {
|
|||||||
fn endpoint_rejects_remote_host() {
|
fn endpoint_rejects_remote_host() {
|
||||||
let err = ModelServerEndpoint::new("http://example.com:8080", 8080).unwrap_err();
|
let err = ModelServerEndpoint::new("http://example.com:8080", 8080).unwrap_err();
|
||||||
assert!(err.to_string().contains("localhost"));
|
assert!(err.to_string().contains("localhost"));
|
||||||
let err =
|
let err = ModelServerEndpoint::new("http://127.0.0.1.evil.com:8080", 8080).unwrap_err();
|
||||||
ModelServerEndpoint::new("http://127.0.0.1.evil.com:8080", 8080).unwrap_err();
|
|
||||||
assert!(err.to_string().contains("localhost"));
|
assert!(err.to_string().contains("localhost"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn auto_start_requires_model_path() {
|
fn auto_start_requires_model_source() {
|
||||||
let endpoint = ModelServerEndpoint::new("http://localhost:8080/v1", 8080).unwrap();
|
let endpoint = ModelServerEndpoint::new("http://localhost:8080/v1", 8080).unwrap();
|
||||||
let model = LocalModelRef::new("qwen", "Qwen", None, "qwen").unwrap();
|
let model = LocalModelRef::new("qwen", "Qwen", None, "qwen").unwrap();
|
||||||
let err = LocalModelServerConfig::new(
|
let err = LocalModelServerConfig::new(
|
||||||
@ -358,11 +547,47 @@ mod tests {
|
|||||||
endpoint,
|
endpoint,
|
||||||
model,
|
model,
|
||||||
None,
|
None,
|
||||||
|
LlamaCppOptions::default(),
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
true,
|
true,
|
||||||
StopPolicy::StopOnAppExit,
|
StopPolicy::StopOnAppExit,
|
||||||
)
|
)
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(err.to_string().contains("model.path"));
|
assert!(err.to_string().contains("model.source"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hf_model_ref_accepts_repo_and_optional_quant() {
|
||||||
|
assert_eq!(
|
||||||
|
HfModelRef::new("Qwen/Qwen3-Coder:Q4_K_M").unwrap().as_str(),
|
||||||
|
"Qwen/Qwen3-Coder:Q4_K_M"
|
||||||
|
);
|
||||||
|
assert!(HfModelRef::new("Qwen/Qwen3 Coder").is_err());
|
||||||
|
assert!(HfModelRef::new("https://huggingface.co/Qwen/Qwen3").is_err());
|
||||||
|
assert!(HfModelRef::new("Qwen").is_err());
|
||||||
|
assert!(HfModelRef::new("a/b:c:d").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_free_args_rejects_reserved_flags_token_and_equals() {
|
||||||
|
for flag in [
|
||||||
|
"--model",
|
||||||
|
"-m",
|
||||||
|
"-hf",
|
||||||
|
"--port",
|
||||||
|
"--host",
|
||||||
|
"-ngl",
|
||||||
|
"--gpu-layers",
|
||||||
|
"-c",
|
||||||
|
"--ctx-size",
|
||||||
|
"--jinja",
|
||||||
|
] {
|
||||||
|
assert!(validate_free_args(&[flag.to_owned()]).is_err(), "{flag}");
|
||||||
|
assert!(
|
||||||
|
validate_free_args(&[format!("{flag}=value")]).is_err(),
|
||||||
|
"{flag}=value"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
validate_free_args(&["--threads".to_owned(), "8".to_owned()]).unwrap();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1014,6 +1014,15 @@ pub trait ManagedProcess: Send + Sync {
|
|||||||
|
|
||||||
/// Builds the argv-structured spawn spec for a local model server.
|
/// Builds the argv-structured spawn spec for a local model server.
|
||||||
pub trait ModelServerRuntime: Send + Sync {
|
pub trait ModelServerRuntime: Send + Sync {
|
||||||
|
/// Builds the pure argv contract for previewing or spawning a local server.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`ModelServerError`] if the config cannot be launched.
|
||||||
|
fn build_argv(
|
||||||
|
&self,
|
||||||
|
config: &LocalModelServerConfig,
|
||||||
|
) -> Result<ModelServerArgv, ModelServerError>;
|
||||||
|
|
||||||
/// Builds a spawn spec from a validated local-server config.
|
/// Builds a spawn spec from a validated local-server config.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
@ -1024,6 +1033,15 @@ pub trait ModelServerRuntime: Send + Sync {
|
|||||||
) -> Result<SpawnSpec, ModelServerError>;
|
) -> Result<SpawnSpec, ModelServerError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pure argv contract for a local model server invocation.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ModelServerArgv {
|
||||||
|
/// Executable command.
|
||||||
|
pub command: String,
|
||||||
|
/// Arguments without shell parsing.
|
||||||
|
pub args: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Persists local model-server configuration in the global IdeA store.
|
/// Persists local model-server configuration in the global IdeA store.
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait ModelServerRegistry: Send + Sync {
|
pub trait ModelServerRegistry: Send + Sync {
|
||||||
|
|||||||
@ -9,12 +9,16 @@ use async_trait::async_trait;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tokio::process::{Child, Command};
|
use tokio::process::{Child, Command};
|
||||||
|
|
||||||
use domain::model_server::{LocalModelServerConfig, LocalModelServerKind, ModelServerEndpoint};
|
use domain::model_server::{
|
||||||
use domain::ports::{
|
ExecutablePath, LlamaCppOptions, LocalModelRef, LocalModelServerConfig, LocalModelServerKind,
|
||||||
FileSystem, ManagedProcess, ManagedProcessHandle, ModelServerError, ModelServerProbe,
|
ModelPath, ModelServerEndpoint, ModelSource,
|
||||||
ModelServerRegistry, ModelServerRuntime, ProcessStatus, RemotePath, SpawnSpec,
|
|
||||||
};
|
};
|
||||||
use domain::{LocalModelServerId, ProjectPath};
|
use domain::ports::{
|
||||||
|
FileSystem, ManagedProcess, ManagedProcessHandle, ModelServerArgv, ModelServerError,
|
||||||
|
ModelServerProbe, ModelServerRegistry, ModelServerRuntime, ProcessStatus, RemotePath,
|
||||||
|
SpawnSpec,
|
||||||
|
};
|
||||||
|
use domain::{LocalModelServerId, ProjectPath, StopPolicy};
|
||||||
|
|
||||||
/// HTTP readiness probe for OpenAI-compatible servers.
|
/// HTTP readiness probe for OpenAI-compatible servers.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@ -77,19 +81,15 @@ impl LlamaCppRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ModelServerRuntime for LlamaCppRuntime {
|
impl ModelServerRuntime for LlamaCppRuntime {
|
||||||
fn build_spawn_spec(
|
fn build_argv(
|
||||||
&self,
|
&self,
|
||||||
config: &LocalModelServerConfig,
|
config: &LocalModelServerConfig,
|
||||||
) -> Result<SpawnSpec, ModelServerError> {
|
) -> Result<ModelServerArgv, ModelServerError> {
|
||||||
if config.kind != LocalModelServerKind::LlamaCpp {
|
if config.kind != LocalModelServerKind::LlamaCpp {
|
||||||
return Err(ModelServerError::Invalid(
|
return Err(ModelServerError::Invalid(
|
||||||
"only LlamaCpp local model servers are supported".to_owned(),
|
"only LlamaCpp local model servers are supported".to_owned(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let model_path =
|
|
||||||
config.model.path.as_ref().ok_or_else(|| {
|
|
||||||
ModelServerError::PathNotAccessible("model.path missing".to_owned())
|
|
||||||
})?;
|
|
||||||
let command = resolve_binary(
|
let command = resolve_binary(
|
||||||
config
|
config
|
||||||
.binary
|
.binary
|
||||||
@ -97,16 +97,49 @@ impl ModelServerRuntime for LlamaCppRuntime {
|
|||||||
.map(|binary| binary.as_str())
|
.map(|binary| binary.as_str())
|
||||||
.unwrap_or("llama-server"),
|
.unwrap_or("llama-server"),
|
||||||
)?;
|
)?;
|
||||||
let mut args = vec![
|
let mut args = Vec::new();
|
||||||
"--model".to_owned(),
|
match config
|
||||||
model_path.as_str().to_owned(),
|
.model
|
||||||
|
.source
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| ModelServerError::PathNotAccessible("model.source missing".to_owned()))?
|
||||||
|
{
|
||||||
|
ModelSource::LocalPath { path } => {
|
||||||
|
args.push("--model".to_owned());
|
||||||
|
args.push(path.as_str().to_owned());
|
||||||
|
}
|
||||||
|
ModelSource::HuggingFace { repo } => {
|
||||||
|
args.push("-hf".to_owned());
|
||||||
|
args.push(repo.as_str().to_owned());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
args.extend([
|
||||||
"--port".to_owned(),
|
"--port".to_owned(),
|
||||||
config.endpoint.port.to_string(),
|
config.endpoint.port.to_string(),
|
||||||
];
|
"--host".to_owned(),
|
||||||
|
config.options.host.clone(),
|
||||||
|
]);
|
||||||
|
if let Some(gpu_layers) = config.options.gpu_layers {
|
||||||
|
args.extend(["-ngl".to_owned(), gpu_layers.to_string()]);
|
||||||
|
}
|
||||||
|
if let Some(context_size) = config.options.context_size {
|
||||||
|
args.extend(["-c".to_owned(), context_size.to_string()]);
|
||||||
|
}
|
||||||
|
if config.options.jinja {
|
||||||
|
args.push("--jinja".to_owned());
|
||||||
|
}
|
||||||
args.extend(config.args.clone());
|
args.extend(config.args.clone());
|
||||||
|
Ok(ModelServerArgv { command, args })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_spawn_spec(
|
||||||
|
&self,
|
||||||
|
config: &LocalModelServerConfig,
|
||||||
|
) -> Result<SpawnSpec, ModelServerError> {
|
||||||
|
let argv = self.build_argv(config)?;
|
||||||
Ok(SpawnSpec {
|
Ok(SpawnSpec {
|
||||||
command,
|
command: argv.command,
|
||||||
args,
|
args: argv.args,
|
||||||
cwd: ProjectPath::new("/").map_err(|e| ModelServerError::Invalid(e.to_string()))?,
|
cwd: ProjectPath::new("/").map_err(|e| ModelServerError::Invalid(e.to_string()))?,
|
||||||
env: Vec::new(),
|
env: Vec::new(),
|
||||||
context_plan: None,
|
context_plan: None,
|
||||||
@ -207,7 +240,7 @@ impl ManagedProcess for LocalManagedProcess {
|
|||||||
|
|
||||||
/// File name of the global local-model-server registry.
|
/// File name of the global local-model-server registry.
|
||||||
const MODEL_SERVERS_FILE: &str = "model-servers.json";
|
const MODEL_SERVERS_FILE: &str = "model-servers.json";
|
||||||
const MODEL_SERVERS_VERSION: u32 = 1;
|
const MODEL_SERVERS_VERSION: u32 = 2;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@ -216,6 +249,82 @@ struct ModelServersDoc {
|
|||||||
servers: Vec<LocalModelServerConfig>,
|
servers: Vec<LocalModelServerConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
enum PersistedModelServersDoc {
|
||||||
|
V2(ModelServersDoc),
|
||||||
|
V1(ModelServersDocV1),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct ModelServersDocV1 {
|
||||||
|
servers: Vec<LocalModelServerConfigV1>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct LocalModelServerConfigV1 {
|
||||||
|
id: LocalModelServerId,
|
||||||
|
kind: LocalModelServerKind,
|
||||||
|
name: String,
|
||||||
|
endpoint: ModelServerEndpoint,
|
||||||
|
model: LocalModelRefV1,
|
||||||
|
#[serde(default)]
|
||||||
|
binary: Option<ExecutablePath>,
|
||||||
|
#[serde(default)]
|
||||||
|
args: Vec<String>,
|
||||||
|
auto_start: bool,
|
||||||
|
#[serde(default = "default_stop_policy")]
|
||||||
|
stop_policy: StopPolicy,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct LocalModelRefV1 {
|
||||||
|
id: String,
|
||||||
|
label: String,
|
||||||
|
#[serde(default)]
|
||||||
|
path: Option<ModelPath>,
|
||||||
|
served_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_stop_policy() -> StopPolicy {
|
||||||
|
StopPolicy::StopOnAppExit
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ModelServersDocV1> for ModelServersDoc {
|
||||||
|
fn from(doc: ModelServersDocV1) -> Self {
|
||||||
|
Self {
|
||||||
|
version: MODEL_SERVERS_VERSION,
|
||||||
|
servers: doc
|
||||||
|
.servers
|
||||||
|
.into_iter()
|
||||||
|
.map(|server| LocalModelServerConfig {
|
||||||
|
id: server.id,
|
||||||
|
kind: server.kind,
|
||||||
|
name: server.name,
|
||||||
|
endpoint: server.endpoint,
|
||||||
|
model: LocalModelRef {
|
||||||
|
id: server.model.id,
|
||||||
|
label: server.model.label,
|
||||||
|
source: server
|
||||||
|
.model
|
||||||
|
.path
|
||||||
|
.map(|path| ModelSource::LocalPath { path }),
|
||||||
|
served_name: server.model.served_name,
|
||||||
|
},
|
||||||
|
binary: server.binary,
|
||||||
|
options: LlamaCppOptions::default(),
|
||||||
|
args: server.args,
|
||||||
|
auto_start: server.auto_start,
|
||||||
|
stop_policy: server.stop_policy,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Default for ModelServersDoc {
|
impl Default for ModelServersDoc {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
@ -249,8 +358,15 @@ impl FsModelServerRegistry {
|
|||||||
|
|
||||||
async fn read_doc(&self) -> Result<ModelServersDoc, ModelServerError> {
|
async fn read_doc(&self) -> Result<ModelServersDoc, ModelServerError> {
|
||||||
match self.fs.read(&self.path()).await {
|
match self.fs.read(&self.path()).await {
|
||||||
Ok(bytes) => serde_json::from_slice(&bytes)
|
Ok(bytes) => match serde_json::from_slice::<PersistedModelServersDoc>(&bytes)
|
||||||
.map_err(|e| ModelServerError::Store(format!("serialization failed: {e}"))),
|
.map_err(|e| ModelServerError::Store(format!("serialization failed: {e}")))?
|
||||||
|
{
|
||||||
|
PersistedModelServersDoc::V2(doc) => Ok(ModelServersDoc {
|
||||||
|
version: MODEL_SERVERS_VERSION,
|
||||||
|
servers: doc.servers,
|
||||||
|
}),
|
||||||
|
PersistedModelServersDoc::V1(doc) => Ok(doc.into()),
|
||||||
|
},
|
||||||
Err(domain::ports::FsError::NotFound(_)) => Ok(ModelServersDoc::default()),
|
Err(domain::ports::FsError::NotFound(_)) => Ok(ModelServersDoc::default()),
|
||||||
Err(domain::ports::FsError::PermissionDenied(p)) => {
|
Err(domain::ports::FsError::PermissionDenied(p)) => {
|
||||||
Err(ModelServerError::PermissionDenied(p))
|
Err(ModelServerError::PermissionDenied(p))
|
||||||
@ -298,7 +414,14 @@ impl ModelServerRegistry for FsModelServerRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn save(&self, config: LocalModelServerConfig) -> Result<(), ModelServerError> {
|
async fn save(&self, config: LocalModelServerConfig) -> Result<(), ModelServerError> {
|
||||||
|
config
|
||||||
|
.options
|
||||||
|
.validate()
|
||||||
|
.map_err(|e| ModelServerError::Invalid(e.to_string()))?;
|
||||||
|
domain::model_server::validate_free_args(&config.args)
|
||||||
|
.map_err(|e| ModelServerError::Invalid(e.to_string()))?;
|
||||||
let mut doc = self.read_doc().await?;
|
let mut doc = self.read_doc().await?;
|
||||||
|
doc.version = MODEL_SERVERS_VERSION;
|
||||||
if let Some(slot) = doc.servers.iter_mut().find(|server| server.id == config.id) {
|
if let Some(slot) = doc.servers.iter_mut().find(|server| server.id == config.id) {
|
||||||
*slot = config;
|
*slot = config;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -4,8 +4,8 @@ use std::path::PathBuf;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use domain::model_server::{
|
use domain::model_server::{
|
||||||
ExecutablePath, LocalModelRef, LocalModelServerConfig, LocalModelServerKind, ModelPath,
|
ExecutablePath, HfModelRef, LlamaCppOptions, LocalModelRef, LocalModelServerConfig,
|
||||||
ModelServerEndpoint, StopPolicy,
|
LocalModelServerKind, ModelPath, ModelServerEndpoint, ModelSource, StopPolicy,
|
||||||
};
|
};
|
||||||
use domain::ports::{FileSystem, ModelServerRegistry, ModelServerRuntime, RemotePath};
|
use domain::ports::{FileSystem, ModelServerRegistry, ModelServerRuntime, RemotePath};
|
||||||
use domain::LocalModelServerId;
|
use domain::LocalModelServerId;
|
||||||
@ -53,12 +53,15 @@ fn config(id: LocalModelServerId, port: u16) -> LocalModelServerConfig {
|
|||||||
LocalModelRef::new(
|
LocalModelRef::new(
|
||||||
"qwen",
|
"qwen",
|
||||||
"Qwen",
|
"Qwen",
|
||||||
Some(ModelPath::new("/models/qwen.gguf").unwrap()),
|
Some(ModelSource::LocalPath {
|
||||||
|
path: ModelPath::new("/models/qwen.gguf").unwrap(),
|
||||||
|
}),
|
||||||
"qwen3-coder-30b",
|
"qwen3-coder-30b",
|
||||||
)
|
)
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
Some(ExecutablePath::new(binary).unwrap()),
|
Some(ExecutablePath::new(binary).unwrap()),
|
||||||
vec!["--ctx-size".to_owned(), "8192".to_owned()],
|
LlamaCppOptions::new("127.0.0.1", Some(35), Some(8192), true).unwrap(),
|
||||||
|
vec!["--threads".to_owned(), "8".to_owned()],
|
||||||
true,
|
true,
|
||||||
StopPolicy::StopOnAppExit,
|
StopPolicy::StopOnAppExit,
|
||||||
)
|
)
|
||||||
@ -67,23 +70,54 @@ fn config(id: LocalModelServerId, port: u16) -> LocalModelServerConfig {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn llamacpp_runtime_builds_structured_argv() {
|
fn llamacpp_runtime_builds_structured_argv() {
|
||||||
let spec = LlamaCppRuntime::new()
|
let argv = LlamaCppRuntime::new()
|
||||||
.build_spawn_spec(&config(sid(1), 8080))
|
.build_argv(&config(sid(1), 8080))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
spec.command,
|
argv.command,
|
||||||
std::env::current_exe().unwrap().to_string_lossy()
|
std::env::current_exe().unwrap().to_string_lossy()
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
spec.args,
|
argv.args,
|
||||||
vec![
|
vec![
|
||||||
"--model",
|
"--model",
|
||||||
"/models/qwen.gguf",
|
"/models/qwen.gguf",
|
||||||
"--port",
|
"--port",
|
||||||
"8080",
|
"8080",
|
||||||
"--ctx-size",
|
"--host",
|
||||||
"8192"
|
"127.0.0.1",
|
||||||
|
"-ngl",
|
||||||
|
"35",
|
||||||
|
"-c",
|
||||||
|
"8192",
|
||||||
|
"--jinja",
|
||||||
|
"--threads",
|
||||||
|
"8"
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn llamacpp_runtime_builds_hf_argv() {
|
||||||
|
let mut config = config(sid(3), 8083);
|
||||||
|
config.model.source = Some(ModelSource::HuggingFace {
|
||||||
|
repo: HfModelRef::new("Qwen/Qwen3-Coder:Q4_K_M").unwrap(),
|
||||||
|
});
|
||||||
|
config.options = LlamaCppOptions::default();
|
||||||
|
config.args = Vec::new();
|
||||||
|
|
||||||
|
let spec = LlamaCppRuntime::new().build_spawn_spec(&config).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
spec.args,
|
||||||
|
vec![
|
||||||
|
"-hf",
|
||||||
|
"Qwen/Qwen3-Coder:Q4_K_M",
|
||||||
|
"--port",
|
||||||
|
"8083",
|
||||||
|
"--host",
|
||||||
|
"127.0.0.1"
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
assert!(spec.context_plan.is_none());
|
assert!(spec.context_plan.is_none());
|
||||||
@ -109,3 +143,49 @@ async fn fs_model_server_registry_roundtrips_global_json() {
|
|||||||
registry.save(updated.clone()).await.unwrap();
|
registry.save(updated.clone()).await.unwrap();
|
||||||
assert_eq!(registry.list().await.unwrap(), vec![updated]);
|
assert_eq!(registry.list().await.unwrap(), vec![updated]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn fs_model_server_registry_migrates_v1_json_to_v2() {
|
||||||
|
let tmp = TempDir::new();
|
||||||
|
let fs: Arc<dyn FileSystem> = Arc::new(LocalFileSystem::new());
|
||||||
|
fs.create_dir_all(&RemotePath::new(tmp.app_data_dir()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
fs.write(
|
||||||
|
&tmp.child("model-servers.json"),
|
||||||
|
br#"{
|
||||||
|
"version": 1,
|
||||||
|
"servers": [{
|
||||||
|
"id": "00000000-0000-0000-0000-000000000004",
|
||||||
|
"kind": "llamaCpp",
|
||||||
|
"name": "Legacy Qwen",
|
||||||
|
"endpoint": { "baseURL": "http://localhost:8084/v1", "port": 8084 },
|
||||||
|
"model": {
|
||||||
|
"id": "qwen",
|
||||||
|
"label": "Qwen",
|
||||||
|
"path": "/models/qwen.gguf",
|
||||||
|
"servedName": "qwen3-coder"
|
||||||
|
},
|
||||||
|
"binary": "/usr/bin/llama-server",
|
||||||
|
"args": ["--ctx-size", "4096"],
|
||||||
|
"autoStart": true,
|
||||||
|
"stopPolicy": "stopOnAppExit"
|
||||||
|
}]
|
||||||
|
}"#,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let registry = FsModelServerRegistry::new(Arc::clone(&fs), tmp.app_data_dir());
|
||||||
|
|
||||||
|
let servers = registry.list().await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(servers.len(), 1);
|
||||||
|
assert_eq!(servers[0].options, LlamaCppOptions::default());
|
||||||
|
assert_eq!(servers[0].args, vec!["--ctx-size", "4096"]);
|
||||||
|
assert_eq!(
|
||||||
|
servers[0].model.source,
|
||||||
|
Some(ModelSource::LocalPath {
|
||||||
|
path: ModelPath::new("/models/qwen.gguf").unwrap()
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -24,6 +24,7 @@ import type {
|
|||||||
LayoutOperation,
|
LayoutOperation,
|
||||||
LayoutTree,
|
LayoutTree,
|
||||||
LocalModelServerConfig,
|
LocalModelServerConfig,
|
||||||
|
ModelServerCommandPreview,
|
||||||
Memory,
|
Memory,
|
||||||
MemoryIndexEntry,
|
MemoryIndexEntry,
|
||||||
MemoryLink,
|
MemoryLink,
|
||||||
@ -1284,6 +1285,44 @@ export class MockModelServerGateway implements ModelServerGateway {
|
|||||||
this.servers = this.servers.filter((s) => s.id !== serverId);
|
this.servers = this.servers.filter((s) => s.id !== serverId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async previewModelServerCommand(
|
||||||
|
config: LocalModelServerConfig,
|
||||||
|
): Promise<ModelServerCommandPreview> {
|
||||||
|
// 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). */
|
/** Test hook: mark a server id as still referenced (delete then rejects). */
|
||||||
markInUse(serverId: string): void {
|
markInUse(serverId: string): void {
|
||||||
this.inUse.add(serverId);
|
this.inUse.add(serverId);
|
||||||
|
|||||||
@ -9,7 +9,10 @@
|
|||||||
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
import type { LocalModelServerConfig } from "@/domain";
|
import type {
|
||||||
|
LocalModelServerConfig,
|
||||||
|
ModelServerCommandPreview,
|
||||||
|
} from "@/domain";
|
||||||
import type { ModelServerGateway } from "@/ports";
|
import type { ModelServerGateway } from "@/ports";
|
||||||
|
|
||||||
export class TauriModelServerGateway implements ModelServerGateway {
|
export class TauriModelServerGateway implements ModelServerGateway {
|
||||||
@ -28,4 +31,14 @@ export class TauriModelServerGateway implements ModelServerGateway {
|
|||||||
async deleteModelServer(serverId: string): Promise<void> {
|
async deleteModelServer(serverId: string): Promise<void> {
|
||||||
await invoke("delete_model_server", { serverId });
|
await invoke("delete_model_server", { serverId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
previewModelServerCommand(
|
||||||
|
config: LocalModelServerConfig,
|
||||||
|
): Promise<ModelServerCommandPreview> {
|
||||||
|
// `preview_model_server_command` takes the config directly (no `request`
|
||||||
|
// wrapper), unlike `save_model_server`.
|
||||||
|
return invoke<ModelServerCommandPreview>("preview_model_server_command", {
|
||||||
|
config,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -39,6 +39,17 @@ export type ModelServerStatus =
|
|||||||
*/
|
*/
|
||||||
export type StopPolicy = "keepAlive" | "stopOnAppExit" | "stopWhenUnused";
|
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
|
* A declared local model server (F35, mirror of the backend flat
|
||||||
* `LocalModelServerConfigDto`, camelCase wire). Global to IdeA (not project
|
* `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).
|
* 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
|
* - `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.
|
* 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 {
|
export interface LocalModelServerConfig {
|
||||||
id: string;
|
id: string;
|
||||||
@ -60,17 +72,39 @@ export interface LocalModelServerConfig {
|
|||||||
/** OpenAI-compatible base URL served by `llama-server` (normalised to `/v1`). */
|
/** OpenAI-compatible base URL served by `llama-server` (normalised to `/v1`). */
|
||||||
baseURL: string;
|
baseURL: string;
|
||||||
port: number;
|
port: number;
|
||||||
/** Absolute `.gguf` path. Required when `autoStart` is `true`. */
|
/** Explicit model source (`.gguf` path or Hugging Face ref). Required for auto-start. */
|
||||||
modelPath?: string;
|
modelSource?: ModelSource;
|
||||||
/** Model name exposed by the server and sent to OpenCode as `model`. */
|
/** Model name exposed by the server and sent to OpenCode as `model`. */
|
||||||
servedModelName: string;
|
servedModelName: string;
|
||||||
/** Explicit `llama-server` executable path/command (optional). */
|
/** Explicit `llama-server` executable path/command (optional). */
|
||||||
binaryPath?: string;
|
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[];
|
args: string[];
|
||||||
autoStart: boolean;
|
autoStart: boolean;
|
||||||
stopPolicy: StopPolicy;
|
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`). */
|
/** A domain event relayed from the backend (tagged union on `type`). */
|
||||||
export type DomainEvent =
|
export type DomainEvent =
|
||||||
| { type: "projectCreated"; projectId: string }
|
| { type: "projectCreated"; projectId: string }
|
||||||
|
|||||||
@ -425,6 +425,8 @@ describe("FirstRunWizard — several local OpenCode profiles (F36)", () => {
|
|||||||
baseURL: "http://localhost:8080/v1",
|
baseURL: "http://localhost:8080/v1",
|
||||||
port: 8080,
|
port: 8080,
|
||||||
servedModelName: "qwen3-coder-30b",
|
servedModelName: "qwen3-coder-30b",
|
||||||
|
host: "127.0.0.1",
|
||||||
|
jinja: false,
|
||||||
args: [],
|
args: [],
|
||||||
autoStart: false,
|
autoStart: false,
|
||||||
stopPolicy: "stopOnAppExit",
|
stopPolicy: "stopOnAppExit",
|
||||||
|
|||||||
@ -1,24 +1,34 @@
|
|||||||
/**
|
/**
|
||||||
* `ModelServersPanel` (F35.2) — declare / edit / delete the local `llama.cpp`
|
* `ModelServersPanel` (F35, wizard V2) — declare / edit / delete the local
|
||||||
* servers an OpenCode profile can bind to. Presentational: all I/O state comes
|
* `llama.cpp` servers an OpenCode profile can bind to. Presentational: all I/O
|
||||||
* from a {@link ModelServersViewModel} passed in (`useModelServers`), so the
|
* state comes from a {@link ModelServersViewModel} passed in (`useModelServers`),
|
||||||
* panel renders and is testable in isolation.
|
* so the panel renders and is testable in isolation.
|
||||||
*
|
*
|
||||||
* A single editable draft is shown at a time (add or edit); the list underneath
|
* The editor is a plain-language wizard: a source segmented control (download
|
||||||
* offers Edit / Delete per server. The `model_server_in_use` rejection surfaces
|
* from Hugging Face vs. a local `.gguf` file), a shared "model name in IdeA"
|
||||||
* as the vm's error banner (a referenced server can't be removed).
|
* 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 { Button, IconButton, Input, Panel, cn } from "@/shared";
|
||||||
import type { ModelServersViewModel } from "./useModelServers";
|
import type { ModelServersViewModel } from "./useModelServers";
|
||||||
import {
|
import {
|
||||||
|
deriveServedModelName,
|
||||||
emptyModelServer,
|
emptyModelServer,
|
||||||
parseArgs,
|
parseArgs,
|
||||||
|
reservedFlagsIn,
|
||||||
validateModelServer,
|
validateModelServer,
|
||||||
type ModelServerErrors,
|
type ModelServerErrors,
|
||||||
|
type ModelSourceKind,
|
||||||
} from "./modelServer";
|
} from "./modelServer";
|
||||||
|
|
||||||
/** Small caption above a control. */
|
/** Small caption above a control. */
|
||||||
@ -26,6 +36,11 @@ function Caption({ children }: { children: React.ReactNode }) {
|
|||||||
return <span className="text-xs font-medium text-muted">{children}</span>;
|
return <span className="text-xs font-medium text-muted">{children}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Muted helper line under a control. */
|
||||||
|
function Hint({ children }: { children: React.ReactNode }) {
|
||||||
|
return <small className="text-xs text-muted">{children}</small>;
|
||||||
|
}
|
||||||
|
|
||||||
const STOP_POLICIES: { value: StopPolicy; label: string }[] = [
|
const STOP_POLICIES: { value: StopPolicy; label: string }[] = [
|
||||||
{ value: "stopOnAppExit", label: "Stop on app exit" },
|
{ value: "stopOnAppExit", label: "Stop on app exit" },
|
||||||
{ value: "keepAlive", label: "Keep alive" },
|
{ value: "keepAlive", label: "Keep alive" },
|
||||||
@ -132,6 +147,7 @@ export function ModelServersPanel({ vm }: ModelServersPanelProps) {
|
|||||||
<ServerEditor
|
<ServerEditor
|
||||||
draft={draft}
|
draft={draft}
|
||||||
busy={vm.busy}
|
busy={vm.busy}
|
||||||
|
preview={vm.preview}
|
||||||
onChange={setDraft}
|
onChange={setDraft}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
vm.clearError();
|
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 (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-pressed={active}
|
||||||
|
onClick={() => onSelect(value)}
|
||||||
|
className={cn(
|
||||||
|
"h-8 flex-1 rounded-md px-3 text-xs font-medium transition-colors",
|
||||||
|
active
|
||||||
|
? "bg-primary text-white"
|
||||||
|
: "bg-raised text-muted hover:text-content",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
aria-label="source du modèle"
|
||||||
|
className="flex gap-1 rounded-md border border-border p-1"
|
||||||
|
>
|
||||||
|
{tab("huggingFace", "Télécharger depuis Hugging Face")}
|
||||||
|
{tab("localPath", "Fichier .gguf local")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 (
|
||||||
|
<section
|
||||||
|
aria-label="command preview"
|
||||||
|
className="flex flex-col gap-1 rounded-md border border-border bg-raised p-2"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Caption>{title}</Caption>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
aria-label="copier la commande"
|
||||||
|
onClick={() => void copy()}
|
||||||
|
disabled={!preview}
|
||||||
|
className="ml-auto"
|
||||||
|
>
|
||||||
|
{copied ? "Copié" : "Copier"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{preview ? (
|
||||||
|
<code
|
||||||
|
aria-label="preview command line"
|
||||||
|
className="whitespace-pre-wrap break-all font-mono text-xs text-content"
|
||||||
|
>
|
||||||
|
{preview.display}
|
||||||
|
</code>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted">
|
||||||
|
Renseigne une source de modèle pour voir la commande.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The add/edit wizard for one server draft. */
|
||||||
function ServerEditor({
|
function ServerEditor({
|
||||||
draft,
|
draft,
|
||||||
busy,
|
busy,
|
||||||
|
preview,
|
||||||
onChange,
|
onChange,
|
||||||
onCancel,
|
onCancel,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
}: {
|
}: {
|
||||||
draft: LocalModelServerConfig;
|
draft: LocalModelServerConfig;
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
|
preview: (
|
||||||
|
config: LocalModelServerConfig,
|
||||||
|
) => Promise<ModelServerCommandPreview | null>;
|
||||||
onChange: (next: LocalModelServerConfig) => void;
|
onChange: (next: LocalModelServerConfig) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
onSubmit: () => void;
|
onSubmit: () => void;
|
||||||
@ -164,17 +278,80 @@ function ServerEditor({
|
|||||||
const patch = (next: Partial<LocalModelServerConfig>) =>
|
const patch = (next: Partial<LocalModelServerConfig>) =>
|
||||||
onChange({ ...draft, ...next });
|
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<ModelSourceKind>(
|
||||||
|
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<LocalModelServerConfig> = { 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<ModelServerCommandPreview | null>(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 (
|
return (
|
||||||
<fieldset
|
<fieldset
|
||||||
aria-label="model server editor"
|
aria-label="model server editor"
|
||||||
className="flex flex-col gap-2 rounded-md border border-primary/40 p-3"
|
className="flex flex-col gap-3 rounded-md border border-primary/40 p-3"
|
||||||
>
|
>
|
||||||
<legend className="px-1 text-xs font-medium text-muted">
|
<legend className="px-1 text-xs font-medium text-muted">
|
||||||
{draft.name.trim().length > 0 ? draft.name : "New server"}
|
{draft.name.trim().length > 0 ? draft.name : "New server"}
|
||||||
</legend>
|
</legend>
|
||||||
|
|
||||||
<label className="flex flex-col gap-1">
|
<label className="flex flex-col gap-1">
|
||||||
<Caption>Name</Caption>
|
<Caption>Nom</Caption>
|
||||||
<Input
|
<Input
|
||||||
aria-label="server name"
|
aria-label="server name"
|
||||||
value={draft.name}
|
value={draft.name}
|
||||||
@ -184,19 +361,88 @@ function ServerEditor({
|
|||||||
{errors.name && <small className="text-xs text-danger">{errors.name}</small>}
|
{errors.name && <small className="text-xs text-danger">{errors.name}</small>}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
{/* --- Source du modèle --------------------------------------------- */}
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<Caption>Source du modèle</Caption>
|
||||||
|
<SourceTabs kind={sourceKind} onSelect={selectKind} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sourceKind === "huggingFace" ? (
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<Caption>Modèle Hugging Face</Caption>
|
||||||
|
<Input
|
||||||
|
aria-label="hugging face model"
|
||||||
|
value={hfRepo}
|
||||||
|
placeholder="unsloth/Qwen3.5-9B-GGUF:Q4_K_M"
|
||||||
|
invalid={Boolean(errors.modelSource)}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
setSource(
|
||||||
|
v.trim().length === 0
|
||||||
|
? undefined
|
||||||
|
: { type: "huggingFace", repo: v },
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Hint>llama.cpp téléchargera/cache le fichier automatiquement.</Hint>
|
||||||
|
{errors.modelSource && (
|
||||||
|
<small className="text-xs text-danger">{errors.modelSource}</small>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
) : (
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<Caption>Fichier .gguf</Caption>
|
||||||
|
<Input
|
||||||
|
aria-label="local gguf file"
|
||||||
|
value={localPath}
|
||||||
|
placeholder="/home/me/models/qwen.gguf"
|
||||||
|
invalid={Boolean(errors.modelSource)}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
setSource(
|
||||||
|
v.trim().length === 0
|
||||||
|
? undefined
|
||||||
|
: { type: "localPath", path: v },
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Hint>Chemin absolu vers un .gguf déjà présent.</Hint>
|
||||||
|
{errors.modelSource && (
|
||||||
|
<small className="text-xs text-danger">{errors.modelSource}</small>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* --- Nom du modèle dans IdeA -------------------------------------- */}
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<Caption>Nom du modèle dans IdeA</Caption>
|
||||||
|
<Input
|
||||||
|
aria-label="server served model name"
|
||||||
|
value={draft.servedModelName}
|
||||||
|
placeholder="qwen3-coder-30b"
|
||||||
|
invalid={Boolean(errors.servedModelName)}
|
||||||
|
onChange={(e) => patch({ servedModelName: e.target.value })}
|
||||||
|
/>
|
||||||
|
<Hint>Nom envoyé aux clients OpenAI-compatible.</Hint>
|
||||||
|
{errors.servedModelName && (
|
||||||
|
<small className="text-xs text-danger">{errors.servedModelName}</small>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* --- Serveur ------------------------------------------------------ */}
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<label className="flex min-w-[12rem] flex-1 flex-col gap-1">
|
<label className="flex min-w-[12rem] flex-1 flex-col gap-1">
|
||||||
<Caption>Base URL</Caption>
|
<Caption>Commande serveur</Caption>
|
||||||
<Input
|
<Input
|
||||||
aria-label="server base url"
|
aria-label="server binary path"
|
||||||
value={draft.baseURL}
|
value={draft.binaryPath ?? ""}
|
||||||
placeholder="http://localhost:8080/v1"
|
placeholder="llama-server"
|
||||||
invalid={Boolean(errors.baseURL)}
|
onChange={(e) => {
|
||||||
onChange={(e) => patch({ baseURL: e.target.value })}
|
const v = e.target.value;
|
||||||
|
patch({ binaryPath: v.length === 0 ? undefined : v });
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
{errors.baseURL && (
|
<Hint>Laisse vide pour résoudre `llama-server` via le PATH.</Hint>
|
||||||
<small className="text-xs text-danger">{errors.baseURL}</small>
|
|
||||||
)}
|
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="flex min-w-[6rem] flex-col gap-1">
|
<label className="flex min-w-[6rem] flex-col gap-1">
|
||||||
@ -213,90 +459,151 @@ function ServerEditor({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className="flex flex-col gap-1">
|
<label className="flex flex-col gap-1">
|
||||||
<Caption>Served model name (sent to OpenCode as `model`)</Caption>
|
<Caption>Base URL (OpenAI-compatible)</Caption>
|
||||||
<Input
|
<Input
|
||||||
aria-label="server served model name"
|
aria-label="server base url"
|
||||||
value={draft.servedModelName}
|
value={draft.baseURL}
|
||||||
placeholder="qwen3-coder-30b"
|
placeholder="http://localhost:8080/v1"
|
||||||
invalid={Boolean(errors.servedModelName)}
|
invalid={Boolean(errors.baseURL)}
|
||||||
onChange={(e) => patch({ servedModelName: e.target.value })}
|
onChange={(e) => patch({ baseURL: e.target.value })}
|
||||||
/>
|
/>
|
||||||
{errors.servedModelName && (
|
{errors.baseURL && (
|
||||||
<small className="text-xs text-danger">{errors.servedModelName}</small>
|
<small className="text-xs text-danger">{errors.baseURL}</small>
|
||||||
)}
|
)}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="flex flex-col gap-1">
|
<label className="flex items-start gap-2">
|
||||||
<Caption>Model path (absolute `.gguf` — required for auto-start)</Caption>
|
<input
|
||||||
<Input
|
type="checkbox"
|
||||||
aria-label="server model path"
|
aria-label="server auto start"
|
||||||
value={draft.modelPath ?? ""}
|
checked={draft.autoStart}
|
||||||
placeholder="/models/qwen3-coder-30b.gguf"
|
disabled={!draft.modelSource}
|
||||||
invalid={Boolean(errors.modelPath)}
|
onChange={(e) => patch({ autoStart: e.target.checked })}
|
||||||
onChange={(e) => {
|
className="mt-1 accent-primary disabled:opacity-50"
|
||||||
const v = e.target.value;
|
|
||||||
patch({ modelPath: v.length === 0 ? undefined : v });
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
{errors.modelPath && (
|
<span className="flex flex-col">
|
||||||
<small className="text-xs text-danger">{errors.modelPath}</small>
|
<Caption>Démarrer automatiquement</Caption>
|
||||||
)}
|
{!draft.modelSource && (
|
||||||
|
<Hint>Renseigne d'abord une source de modèle pour l'activer.</Hint>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="flex flex-col gap-1">
|
{/* --- Réglages llama.cpp (avancé) --------------------------------- */}
|
||||||
<Caption>Binary path (optional — `llama-server` command)</Caption>
|
<details className="rounded-md border border-border p-2">
|
||||||
<Input
|
<summary className="cursor-pointer text-xs font-medium text-muted">
|
||||||
aria-label="server binary path"
|
Réglages llama.cpp
|
||||||
value={draft.binaryPath ?? ""}
|
</summary>
|
||||||
placeholder="llama-server"
|
<div className="mt-2 flex flex-col gap-2">
|
||||||
onChange={(e) => {
|
<div className="flex flex-wrap gap-2">
|
||||||
const v = e.target.value;
|
<label className="flex min-w-[8rem] flex-1 flex-col gap-1">
|
||||||
patch({ binaryPath: v.length === 0 ? undefined : v });
|
<Caption>
|
||||||
}}
|
Couches GPU <span className="text-muted">(-ngl)</span>
|
||||||
/>
|
</Caption>
|
||||||
</label>
|
<Input
|
||||||
|
aria-label="gpu layers"
|
||||||
|
value={draft.gpuLayers === undefined ? "" : String(draft.gpuLayers)}
|
||||||
|
inputMode="numeric"
|
||||||
|
placeholder="99"
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value.trim();
|
||||||
|
patch({
|
||||||
|
gpuLayers: v.length === 0 ? undefined : Number.parseInt(v, 10),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Hint>0 = CPU seul.</Hint>
|
||||||
|
</label>
|
||||||
|
|
||||||
<label className="flex flex-col gap-1">
|
<label className="flex min-w-[8rem] flex-1 flex-col gap-1">
|
||||||
<Caption>Extra arguments</Caption>
|
<Caption>
|
||||||
<Input
|
Taille de contexte <span className="text-muted">(-c)</span>
|
||||||
aria-label="server args"
|
</Caption>
|
||||||
value={draft.args.join(" ")}
|
<Input
|
||||||
placeholder="--ctx-size 8192 --n-gpu-layers 99"
|
aria-label="context size"
|
||||||
onChange={(e) => patch({ args: parseArgs(e.target.value) })}
|
value={
|
||||||
/>
|
draft.contextSize === undefined ? "" : String(draft.contextSize)
|
||||||
</label>
|
}
|
||||||
|
inputMode="numeric"
|
||||||
|
placeholder="16384"
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value.trim();
|
||||||
|
patch({
|
||||||
|
contextSize:
|
||||||
|
v.length === 0 ? undefined : Number.parseInt(v, 10),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-4">
|
<label className="flex flex-col gap-1">
|
||||||
<label className="flex items-center gap-2">
|
<Caption>
|
||||||
<input
|
Adresse d'écoute <span className="text-muted">(--host)</span>
|
||||||
type="checkbox"
|
</Caption>
|
||||||
aria-label="server auto start"
|
<Input
|
||||||
checked={draft.autoStart}
|
aria-label="listen host"
|
||||||
onChange={(e) => patch({ autoStart: e.target.checked })}
|
value={draft.host}
|
||||||
className="accent-primary"
|
placeholder="127.0.0.1"
|
||||||
/>
|
invalid={Boolean(errors.host)}
|
||||||
<Caption>Auto-start</Caption>
|
onChange={(e) => patch({ host: e.target.value })}
|
||||||
</label>
|
/>
|
||||||
|
{errors.host && (
|
||||||
<label className="flex items-center gap-2">
|
<small className="text-xs text-danger">{errors.host}</small>
|
||||||
<Caption>Stop policy</Caption>
|
|
||||||
<select
|
|
||||||
aria-label="server stop policy"
|
|
||||||
value={draft.stopPolicy}
|
|
||||||
onChange={(e) => patch({ stopPolicy: e.target.value as StopPolicy })}
|
|
||||||
className={cn(
|
|
||||||
"h-8 rounded-md bg-raised px-2 text-xs text-content",
|
|
||||||
"border border-border outline-none focus:border-primary",
|
|
||||||
)}
|
)}
|
||||||
>
|
</label>
|
||||||
{STOP_POLICIES.map((p) => (
|
|
||||||
<option key={p.value} value={p.value}>
|
<label className="flex items-center gap-2">
|
||||||
{p.label}
|
<input
|
||||||
</option>
|
type="checkbox"
|
||||||
))}
|
aria-label="jinja template"
|
||||||
</select>
|
checked={draft.jinja}
|
||||||
</label>
|
onChange={(e) => patch({ jinja: e.target.checked })}
|
||||||
</div>
|
className="accent-primary"
|
||||||
|
/>
|
||||||
|
<Caption>
|
||||||
|
Template chat Jinja <span className="text-muted">(--jinja)</span>
|
||||||
|
</Caption>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<Caption>Arguments supplémentaires</Caption>
|
||||||
|
<Input
|
||||||
|
aria-label="extra arguments"
|
||||||
|
value={draft.args.join(" ")}
|
||||||
|
placeholder="--flash-attn --parallel 2"
|
||||||
|
onChange={(e) => patch({ args: parseArgs(e.target.value) })}
|
||||||
|
/>
|
||||||
|
{reservedHits.length > 0 && (
|
||||||
|
<small className="text-xs text-warning" role="status">
|
||||||
|
{reservedHits.join(", ")} : cette option est déjà pilotée par un
|
||||||
|
champ ci-dessus.
|
||||||
|
</small>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<Caption>Stop policy</Caption>
|
||||||
|
<select
|
||||||
|
aria-label="server stop policy"
|
||||||
|
value={draft.stopPolicy}
|
||||||
|
onChange={(e) => patch({ stopPolicy: e.target.value as StopPolicy })}
|
||||||
|
className={cn(
|
||||||
|
"h-8 rounded-md bg-raised px-2 text-xs text-content",
|
||||||
|
"border border-border outline-none focus:border-primary",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{STOP_POLICIES.map((p) => (
|
||||||
|
<option key={p.value} value={p.value}>
|
||||||
|
{p.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<CommandPreview preview={cmd} autoStart={draft.autoStart} />
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@ -14,6 +14,11 @@ export {
|
|||||||
validateModelServer,
|
validateModelServer,
|
||||||
isModelServerValid,
|
isModelServerValid,
|
||||||
isAbsolutePath,
|
isAbsolutePath,
|
||||||
|
isValidHfRef,
|
||||||
parseArgs,
|
parseArgs,
|
||||||
|
deriveServedModelName,
|
||||||
|
reservedFlagsIn,
|
||||||
|
RESERVED_LLAMACPP_FLAGS,
|
||||||
type ModelServerErrors,
|
type ModelServerErrors,
|
||||||
|
type ModelSourceKind,
|
||||||
} from "./modelServer";
|
} from "./modelServer";
|
||||||
|
|||||||
@ -1,20 +1,24 @@
|
|||||||
/**
|
/**
|
||||||
* Pure, framework-free helpers for the local model server feature (F35). Draft
|
* Pure, framework-free helpers for the local model server feature (F35, V2 with
|
||||||
* validation mirrors the backend invariants (`ModelServerEndpoint::new`,
|
* the Hugging Face source). Draft validation mirrors the backend invariants
|
||||||
* `LocalModelRef::new`, `LocalModelServerConfig::new`) so the form can surface
|
* (`ModelServerEndpoint::new`, `ModelPath::new`, `HfModelRef::new`,
|
||||||
|
* `LlamaCppOptions::new`, `LocalModelServerConfig::new`) so the form can surface
|
||||||
* errors before any `invoke`. No React, no Tauri here.
|
* errors before any `invoke`. No React, no Tauri here.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { LocalModelServerConfig } from "@/domain";
|
import type { LocalModelServerConfig, ModelSource } from "@/domain";
|
||||||
|
|
||||||
/** A field-keyed validation error map (empty ⇒ valid). */
|
/** A field-keyed validation error map (empty ⇒ valid). */
|
||||||
export type ModelServerErrors = Partial<
|
export type ModelServerErrors = Partial<
|
||||||
Record<
|
Record<
|
||||||
"name" | "baseURL" | "port" | "servedModelName" | "modelPath",
|
"name" | "baseURL" | "port" | "servedModelName" | "modelSource" | "host",
|
||||||
string
|
string
|
||||||
>
|
>
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
/** Which source tab is active in the editor (kept even when the field is blank). */
|
||||||
|
export type ModelSourceKind = "huggingFace" | "localPath";
|
||||||
|
|
||||||
/** Whether a string is a valid `http://`/`https://` URL (mirror of backend). */
|
/** Whether a string is a valid `http://`/`https://` URL (mirror of backend). */
|
||||||
function isValidHttpUrl(v: string): boolean {
|
function isValidHttpUrl(v: string): boolean {
|
||||||
if (v.trim().length === 0) return false;
|
if (v.trim().length === 0) return false;
|
||||||
@ -31,6 +35,23 @@ export function isAbsolutePath(path: string): boolean {
|
|||||||
return /^[a-zA-Z]:[/\\]/.test(path);
|
return /^[a-zA-Z]:[/\\]/.test(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a Hugging Face reference is well-formed (mirror of the backend
|
||||||
|
* `HfModelRef::new`): `namespace/repo` or `namespace/repo:quant`, no spaces, not
|
||||||
|
* an URL, at most one `:`. Blank is handled by the caller.
|
||||||
|
*/
|
||||||
|
export function isValidHfRef(repo: string): boolean {
|
||||||
|
if (/\s/.test(repo)) return false;
|
||||||
|
if (repo.startsWith("http://") || repo.startsWith("https://")) return false;
|
||||||
|
if ((repo.match(/:/g) ?? []).length > 1) return false;
|
||||||
|
const [base, quant] = repo.includes(":")
|
||||||
|
? [repo.slice(0, repo.indexOf(":")), repo.slice(repo.indexOf(":") + 1)]
|
||||||
|
: [repo, undefined];
|
||||||
|
if (quant !== undefined && quant.length === 0) return false;
|
||||||
|
const slash = base.split("/");
|
||||||
|
return slash.length === 2 && slash[0].length > 0 && slash[1].length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates a {@link LocalModelServerConfig} draft the way the backend would.
|
* Validates a {@link LocalModelServerConfig} draft the way the backend would.
|
||||||
* Returns an empty object when the draft is valid.
|
* Returns an empty object when the draft is valid.
|
||||||
@ -45,15 +66,32 @@ export function validateModelServer(c: LocalModelServerConfig): ModelServerError
|
|||||||
errors.port = "Port must be an integer between 1 and 65535.";
|
errors.port = "Port must be an integer between 1 and 65535.";
|
||||||
}
|
}
|
||||||
if (c.servedModelName.trim().length === 0) {
|
if (c.servedModelName.trim().length === 0) {
|
||||||
errors.servedModelName = "Served model name is required.";
|
errors.servedModelName = "Le nom du modèle est requis.";
|
||||||
}
|
}
|
||||||
const modelPath = c.modelPath?.trim() ?? "";
|
if (c.host.trim().length === 0 || /\s/.test(c.host)) {
|
||||||
if (modelPath.length > 0 && !isAbsolutePath(modelPath)) {
|
errors.host = "L'adresse d'écoute est requise et ne doit pas contenir d'espace.";
|
||||||
errors.modelPath = "Model path must be absolute.";
|
|
||||||
}
|
}
|
||||||
// The backend rejects auto-start without an absolute model path.
|
|
||||||
if (c.autoStart && modelPath.length === 0) {
|
const source = c.modelSource;
|
||||||
errors.modelPath = "Auto-start requires an absolute model path.";
|
if (source) {
|
||||||
|
if (source.type === "localPath") {
|
||||||
|
if (source.path.trim().length === 0) {
|
||||||
|
errors.modelSource = "Le chemin du fichier .gguf est requis.";
|
||||||
|
} else if (!isAbsolutePath(source.path.trim())) {
|
||||||
|
errors.modelSource = "Le chemin du fichier doit être absolu.";
|
||||||
|
}
|
||||||
|
} else if (source.repo.trim().length === 0) {
|
||||||
|
errors.modelSource = "Le modèle Hugging Face est requis.";
|
||||||
|
} else if (!isValidHfRef(source.repo.trim())) {
|
||||||
|
errors.modelSource =
|
||||||
|
"Format attendu : auteur/modèle ou auteur/modèle:quant (ex. unsloth/Qwen3.5-9B-GGUF:Q4_K_M).";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The backend rejects auto-start without a model source.
|
||||||
|
if (c.autoStart && !source) {
|
||||||
|
errors.modelSource =
|
||||||
|
"Le démarrage automatique nécessite une source de modèle.";
|
||||||
}
|
}
|
||||||
return errors;
|
return errors;
|
||||||
}
|
}
|
||||||
@ -72,7 +110,8 @@ export function newModelServerId(): string {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* A fresh model-server draft (id minted client-side) pre-filled with a sensible
|
* A fresh model-server draft (id minted client-side) pre-filled with a sensible
|
||||||
* local `llama-server` endpoint, so the form starts valid apart from the name.
|
* local `llama-server` endpoint, so the form starts valid apart from the name
|
||||||
|
* and the (still empty) model source.
|
||||||
*/
|
*/
|
||||||
export function emptyModelServer(): LocalModelServerConfig {
|
export function emptyModelServer(): LocalModelServerConfig {
|
||||||
return {
|
return {
|
||||||
@ -81,7 +120,9 @@ export function emptyModelServer(): LocalModelServerConfig {
|
|||||||
name: "",
|
name: "",
|
||||||
baseURL: "http://localhost:8080/v1",
|
baseURL: "http://localhost:8080/v1",
|
||||||
port: 8080,
|
port: 8080,
|
||||||
servedModelName: "qwen3-coder-30b",
|
servedModelName: "",
|
||||||
|
host: "127.0.0.1",
|
||||||
|
jinja: false,
|
||||||
args: [],
|
args: [],
|
||||||
autoStart: false,
|
autoStart: false,
|
||||||
stopPolicy: "stopOnAppExit",
|
stopPolicy: "stopOnAppExit",
|
||||||
@ -95,3 +136,54 @@ export function parseArgs(raw: string): string[] {
|
|||||||
.map((s) => s.trim())
|
.map((s) => s.trim())
|
||||||
.filter((s) => s.length > 0);
|
.filter((s) => s.length > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derives a default served-model name from a source when the user has not typed
|
||||||
|
* one yet: the file stem for a local path, or the repo name (without namespace
|
||||||
|
* and quant) for a Hugging Face reference. Returns `""` when nothing usable.
|
||||||
|
*/
|
||||||
|
export function deriveServedModelName(source: ModelSource | undefined): string {
|
||||||
|
if (!source) return "";
|
||||||
|
if (source.type === "localPath") {
|
||||||
|
const path = source.path.trim();
|
||||||
|
if (path.length === 0) return "";
|
||||||
|
const base = path.split(/[/\\]/).pop() ?? "";
|
||||||
|
return base.replace(/\.gguf$/i, "");
|
||||||
|
}
|
||||||
|
const repo = source.repo.trim();
|
||||||
|
if (repo.length === 0) return "";
|
||||||
|
const base = repo.includes(":") ? repo.slice(0, repo.indexOf(":")) : repo;
|
||||||
|
return base.split("/").pop() ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* llama.cpp flags already piloted by dedicated editor fields. Typing any of them
|
||||||
|
* (bare or as `--flag=value`) in the free "extra arguments" field is a
|
||||||
|
* conflict: the backend stays the authority, but we mirror a soft warning.
|
||||||
|
*/
|
||||||
|
export const RESERVED_LLAMACPP_FLAGS: readonly string[] = [
|
||||||
|
"--model",
|
||||||
|
"-m",
|
||||||
|
"-hf",
|
||||||
|
"--port",
|
||||||
|
"--host",
|
||||||
|
"-ngl",
|
||||||
|
"--gpu-layers",
|
||||||
|
"-c",
|
||||||
|
"--ctx-size",
|
||||||
|
"--jinja",
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the reserved flags present in a free-form args list (matching bare
|
||||||
|
* tokens and the `--flag=value` form), or an empty list when none clash.
|
||||||
|
*/
|
||||||
|
export function reservedFlagsIn(args: string[]): string[] {
|
||||||
|
const reserved = new Set(RESERVED_LLAMACPP_FLAGS);
|
||||||
|
const hits = new Set<string>();
|
||||||
|
for (const token of args) {
|
||||||
|
const flag = token.includes("=") ? token.slice(0, token.indexOf("=")) : token;
|
||||||
|
if (reserved.has(flag)) hits.add(flag);
|
||||||
|
}
|
||||||
|
return [...hits];
|
||||||
|
}
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* F35.2 — local model server registry: the pure validation helpers, the
|
* F35 (wizard V2) — local model server registry: the pure validation/derivation
|
||||||
* {@link useModelServers} hook behind the real DIProvider + mock gateway, the
|
* helpers, the {@link useModelServers} hook behind the real DIProvider + mock
|
||||||
* {@link ModelServersPanel} CRUD UI (incl. the `model_server_in_use` rejection),
|
* gateway, the {@link ModelServersPanel} wizard (source toggle, reserved-flag
|
||||||
* and the {@link ModelServerSelect} binding dropdown.
|
* mirror, V2 save payload, served-name prefill, command preview), and the
|
||||||
|
* {@link ModelServerSelect} binding dropdown.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
@ -16,8 +17,11 @@ import { useModelServers } from "./useModelServers";
|
|||||||
import { ModelServersPanel } from "./ModelServersPanel";
|
import { ModelServersPanel } from "./ModelServersPanel";
|
||||||
import { ModelServerSelect } from "./ModelServerSelect";
|
import { ModelServerSelect } from "./ModelServerSelect";
|
||||||
import {
|
import {
|
||||||
|
deriveServedModelName,
|
||||||
emptyModelServer,
|
emptyModelServer,
|
||||||
isAbsolutePath,
|
isAbsolutePath,
|
||||||
|
isValidHfRef,
|
||||||
|
reservedFlagsIn,
|
||||||
validateModelServer,
|
validateModelServer,
|
||||||
} from "./modelServer";
|
} from "./modelServer";
|
||||||
|
|
||||||
@ -27,7 +31,10 @@ const SERVER: LocalModelServerConfig = {
|
|||||||
name: "Local A",
|
name: "Local A",
|
||||||
baseURL: "http://localhost:8080/v1",
|
baseURL: "http://localhost:8080/v1",
|
||||||
port: 8080,
|
port: 8080,
|
||||||
|
modelSource: { type: "huggingFace", repo: "unsloth/Qwen3.5-9B-GGUF:Q4_K_M" },
|
||||||
servedModelName: "qwen3-coder-30b",
|
servedModelName: "qwen3-coder-30b",
|
||||||
|
host: "127.0.0.1",
|
||||||
|
jinja: false,
|
||||||
args: [],
|
args: [],
|
||||||
autoStart: false,
|
autoStart: false,
|
||||||
stopPolicy: "stopOnAppExit",
|
stopPolicy: "stopOnAppExit",
|
||||||
@ -46,10 +53,16 @@ function setup(modelServer = new MockModelServerGateway()) {
|
|||||||
// Pure helpers
|
// Pure helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
describe("modelServer helpers (F35.2)", () => {
|
describe("modelServer helpers (F35 V2)", () => {
|
||||||
it("emptyModelServer starts valid apart from the name", () => {
|
it("emptyModelServer needs a name and a served model name, and starts jinja-off on 127.0.0.1", () => {
|
||||||
const draft = emptyModelServer();
|
const draft = emptyModelServer();
|
||||||
expect(validateModelServer(draft)).toEqual({ name: "Name is required." });
|
expect(draft.host).toBe("127.0.0.1");
|
||||||
|
expect(draft.jinja).toBe(false);
|
||||||
|
expect(draft.modelSource).toBeUndefined();
|
||||||
|
expect(validateModelServer(draft)).toEqual({
|
||||||
|
name: "Name is required.",
|
||||||
|
servedModelName: "Le nom du modèle est requis.",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("flags a non-http base URL and an out-of-range port", () => {
|
it("flags a non-http base URL and an out-of-range port", () => {
|
||||||
@ -62,14 +75,37 @@ describe("modelServer helpers (F35.2)", () => {
|
|||||||
expect(errors.port).toBeTruthy();
|
expect(errors.port).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("requires an absolute model path, and requires it when auto-start is on", () => {
|
it("validates the local path source (absolute) and requires a source for auto-start", () => {
|
||||||
expect(validateModelServer({ ...SERVER, modelPath: "relative/x.gguf" }).modelPath).toBeTruthy();
|
|
||||||
expect(validateModelServer({ ...SERVER, autoStart: true }).modelPath).toMatch(
|
|
||||||
/auto-start/i,
|
|
||||||
);
|
|
||||||
expect(
|
expect(
|
||||||
validateModelServer({ ...SERVER, autoStart: true, modelPath: "/m/x.gguf" }).modelPath,
|
validateModelServer({
|
||||||
|
...SERVER,
|
||||||
|
modelSource: { type: "localPath", path: "relative/x.gguf" },
|
||||||
|
}).modelSource,
|
||||||
|
).toBeTruthy();
|
||||||
|
expect(
|
||||||
|
validateModelServer({
|
||||||
|
...SERVER,
|
||||||
|
modelSource: { type: "localPath", path: "/m/x.gguf" },
|
||||||
|
}).modelSource,
|
||||||
).toBeUndefined();
|
).toBeUndefined();
|
||||||
|
expect(
|
||||||
|
validateModelServer({ ...SERVER, modelSource: undefined, autoStart: true })
|
||||||
|
.modelSource,
|
||||||
|
).toMatch(/démarrage automatique/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validates the Hugging Face source format", () => {
|
||||||
|
expect(isValidHfRef("unsloth/Qwen3.5-9B-GGUF")).toBe(true);
|
||||||
|
expect(isValidHfRef("unsloth/Qwen3.5-9B-GGUF:Q4_K_M")).toBe(true);
|
||||||
|
expect(isValidHfRef("https://huggingface.co/x/y")).toBe(false);
|
||||||
|
expect(isValidHfRef("just-a-name")).toBe(false);
|
||||||
|
expect(isValidHfRef("a/b:c:d")).toBe(false);
|
||||||
|
expect(
|
||||||
|
validateModelServer({
|
||||||
|
...SERVER,
|
||||||
|
modelSource: { type: "huggingFace", repo: "not valid ref" },
|
||||||
|
}).modelSource,
|
||||||
|
).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("recognises absolute POSIX and Windows paths", () => {
|
it("recognises absolute POSIX and Windows paths", () => {
|
||||||
@ -77,13 +113,30 @@ describe("modelServer helpers (F35.2)", () => {
|
|||||||
expect(isAbsolutePath("C:/models/x.gguf")).toBe(true);
|
expect(isAbsolutePath("C:/models/x.gguf")).toBe(true);
|
||||||
expect(isAbsolutePath("models/x.gguf")).toBe(false);
|
expect(isAbsolutePath("models/x.gguf")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("derives a served model name from either source", () => {
|
||||||
|
expect(
|
||||||
|
deriveServedModelName({ type: "localPath", path: "/models/qwen3-coder.gguf" }),
|
||||||
|
).toBe("qwen3-coder");
|
||||||
|
expect(
|
||||||
|
deriveServedModelName({ type: "huggingFace", repo: "unsloth/Qwen3.5-9B-GGUF:Q4_K_M" }),
|
||||||
|
).toBe("Qwen3.5-9B-GGUF");
|
||||||
|
expect(deriveServedModelName(undefined)).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("spots reserved llama.cpp flags typed in the free args, incl. --flag=value", () => {
|
||||||
|
expect(reservedFlagsIn(["--flash-attn", "--parallel", "2"])).toEqual([]);
|
||||||
|
expect(reservedFlagsIn(["--model", "/x.gguf"])).toEqual(["--model"]);
|
||||||
|
expect(reservedFlagsIn(["--host=0.0.0.0"])).toEqual(["--host"]);
|
||||||
|
expect(reservedFlagsIn(["-ngl", "99"])).toEqual(["-ngl"]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Hook
|
// Hook
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
describe("useModelServers (F35.2)", () => {
|
describe("useModelServers (F35 V2)", () => {
|
||||||
it("saves and lists servers", async () => {
|
it("saves and lists servers", async () => {
|
||||||
const { view } = setup();
|
const { view } = setup();
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@ -119,6 +172,18 @@ describe("useModelServers (F35.2)", () => {
|
|||||||
expect(view.result.current.error).toMatch(/encore utilisé par un profil/i);
|
expect(view.result.current.error).toMatch(/encore utilisé par un profil/i);
|
||||||
expect(view.result.current.servers).toHaveLength(1);
|
expect(view.result.current.servers).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("previews a command for a valid draft and returns null for a source-less one", async () => {
|
||||||
|
const { view } = setup();
|
||||||
|
const withSource = await view.result.current.preview(SERVER);
|
||||||
|
expect(withSource?.args).toContain("-hf");
|
||||||
|
expect(withSource?.display).toMatch(/unsloth\/Qwen3\.5-9B-GGUF:Q4_K_M/);
|
||||||
|
const withoutSource = await view.result.current.preview({
|
||||||
|
...SERVER,
|
||||||
|
modelSource: undefined,
|
||||||
|
});
|
||||||
|
expect(withoutSource).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -141,35 +206,95 @@ function renderPanel(modelServer = new MockModelServerGateway()) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("ModelServersPanel (F35.2)", () => {
|
describe("ModelServersPanel wizard (F35 V2)", () => {
|
||||||
it("declares a new server end-to-end", async () => {
|
it("declares a Hugging Face server end-to-end with a V2 payload", async () => {
|
||||||
const { modelServer } = renderPanel();
|
const { modelServer } = renderPanel();
|
||||||
fireEvent.click(screen.getByLabelText("add model server"));
|
fireEvent.click(screen.getByLabelText("add model server"));
|
||||||
|
|
||||||
fireEvent.change(screen.getByLabelText("server name"), {
|
fireEvent.change(screen.getByLabelText("server name"), {
|
||||||
target: { value: "Local A" },
|
target: { value: "Local A" },
|
||||||
});
|
});
|
||||||
// A valid draft (pre-filled endpoint + servedModelName) enables Save once
|
fireEvent.change(screen.getByLabelText("hugging face model"), {
|
||||||
// the initial passive load has settled.
|
target: { value: "unsloth/Qwen3.5-9B-GGUF:Q4_K_M" },
|
||||||
|
});
|
||||||
|
|
||||||
const save = screen.getByLabelText("save model server") as HTMLButtonElement;
|
const save = screen.getByLabelText("save model server") as HTMLButtonElement;
|
||||||
await waitFor(() => expect(save.disabled).toBe(false));
|
await waitFor(() => expect(save.disabled).toBe(false));
|
||||||
fireEvent.click(save);
|
fireEvent.click(save);
|
||||||
|
|
||||||
await waitFor(async () => {
|
await waitFor(async () => {
|
||||||
const saved = await modelServer.listModelServers();
|
const saved = await modelServer.listModelServers();
|
||||||
expect(saved.map((s) => s.name)).toEqual(["Local A"]);
|
expect(saved).toHaveLength(1);
|
||||||
});
|
});
|
||||||
// The editor closed and the server shows in the list.
|
const [saved] = await modelServer.listModelServers();
|
||||||
|
// Payload conforms to the V2 DTO: modelSource (not modelPath), host, jinja.
|
||||||
|
expect(saved.modelSource).toEqual({
|
||||||
|
type: "huggingFace",
|
||||||
|
repo: "unsloth/Qwen3.5-9B-GGUF:Q4_K_M",
|
||||||
|
});
|
||||||
|
expect(saved.host).toBe("127.0.0.1");
|
||||||
|
expect(saved.jinja).toBe(false);
|
||||||
|
expect("modelPath" in saved).toBe(false);
|
||||||
expect(screen.getByLabelText("edit Local A")).toBeTruthy();
|
expect(screen.getByLabelText("edit Local A")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("blocks Save while the draft is invalid (blank name)", () => {
|
it("toggles between the Hugging Face and local .gguf source fields", () => {
|
||||||
renderPanel();
|
renderPanel();
|
||||||
fireEvent.click(screen.getByLabelText("add model server"));
|
fireEvent.click(screen.getByLabelText("add model server"));
|
||||||
// Invalid draft (blank name) keeps Save disabled regardless of busy.
|
|
||||||
|
// HF tab is the default.
|
||||||
|
expect(screen.getByLabelText("hugging face model")).toBeTruthy();
|
||||||
|
expect(screen.queryByLabelText("local gguf file")).toBeNull();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /fichier \.gguf local/i }));
|
||||||
|
expect(screen.getByLabelText("local gguf file")).toBeTruthy();
|
||||||
|
expect(screen.queryByLabelText("hugging face model")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefills the served model name from the source when left blank", () => {
|
||||||
|
renderPanel();
|
||||||
|
fireEvent.click(screen.getByLabelText("add model server"));
|
||||||
|
fireEvent.change(screen.getByLabelText("hugging face model"), {
|
||||||
|
target: { value: "unsloth/Qwen3.5-9B-GGUF:Q4_K_M" },
|
||||||
|
});
|
||||||
expect(
|
expect(
|
||||||
(screen.getByLabelText("save model server") as HTMLButtonElement).disabled,
|
(screen.getByLabelText("server served model name") as HTMLInputElement).value,
|
||||||
).toBe(true);
|
).toBe("Qwen3.5-9B-GGUF");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mirrors a soft warning when a reserved flag is typed in the extra args", () => {
|
||||||
|
renderPanel();
|
||||||
|
fireEvent.click(screen.getByLabelText("add model server"));
|
||||||
|
fireEvent.change(screen.getByLabelText("extra arguments"), {
|
||||||
|
target: { value: "--host 0.0.0.0" },
|
||||||
|
});
|
||||||
|
expect(screen.getByText(/déjà pilotée par un champ/i)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks auto-start until a model source is set", () => {
|
||||||
|
renderPanel();
|
||||||
|
fireEvent.click(screen.getByLabelText("add model server"));
|
||||||
|
const autoStart = screen.getByLabelText("server auto start") as HTMLInputElement;
|
||||||
|
expect(autoStart.disabled).toBe(true);
|
||||||
|
fireEvent.change(screen.getByLabelText("hugging face model"), {
|
||||||
|
target: { value: "unsloth/Qwen3.5-9B-GGUF:Q4_K_M" },
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
(screen.getByLabelText("server auto start") as HTMLInputElement).disabled,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the backend-built command preview after a source is entered", async () => {
|
||||||
|
renderPanel();
|
||||||
|
fireEvent.click(screen.getByLabelText("add model server"));
|
||||||
|
fireEvent.change(screen.getByLabelText("hugging face model"), {
|
||||||
|
target: { value: "unsloth/Qwen3.5-9B-GGUF:Q4_K_M" },
|
||||||
|
});
|
||||||
|
const line = (await screen.findByLabelText(
|
||||||
|
"preview command line",
|
||||||
|
)) as HTMLElement;
|
||||||
|
expect(line.textContent).toMatch(/-hf unsloth\/Qwen3\.5-9B-GGUF:Q4_K_M/);
|
||||||
|
expect(line.textContent).toMatch(/--port 8080/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows an actionable banner when deleting a referenced server", async () => {
|
it("shows an actionable banner when deleting a referenced server", async () => {
|
||||||
@ -184,7 +309,6 @@ describe("ModelServersPanel (F35.2)", () => {
|
|||||||
expect((await screen.findByRole("alert")).textContent).toMatch(
|
expect((await screen.findByRole("alert")).textContent).toMatch(
|
||||||
/encore utilisé par un profil/i,
|
/encore utilisé par un profil/i,
|
||||||
);
|
);
|
||||||
// The server is still listed (delete was refused).
|
|
||||||
expect(screen.getByLabelText("edit Local A")).toBeTruthy();
|
expect(screen.getByLabelText("edit Local A")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -210,7 +334,7 @@ describe("ModelServersPanel (F35.2)", () => {
|
|||||||
// Select
|
// Select
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
describe("ModelServerSelect (F35.2)", () => {
|
describe("ModelServerSelect (F35)", () => {
|
||||||
it("offers a 'none' option plus each declared server, and emits the id", () => {
|
it("offers a 'none' option plus each declared server, and emits the id", () => {
|
||||||
let picked: string | undefined = "sentinel";
|
let picked: string | undefined = "sentinel";
|
||||||
render(
|
render(
|
||||||
@ -221,7 +345,6 @@ describe("ModelServerSelect (F35.2)", () => {
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
const select = screen.getByLabelText("local model server") as HTMLSelectElement;
|
const select = screen.getByLabelText("local model server") as HTMLSelectElement;
|
||||||
// "None" is selected when value is undefined.
|
|
||||||
expect(select.value).toBe("");
|
expect(select.value).toBe("");
|
||||||
fireEvent.change(select, { target: { value: SERVER.id } });
|
fireEvent.change(select, { target: { value: SERVER.id } });
|
||||||
expect(picked).toBe(SERVER.id);
|
expect(picked).toBe(SERVER.id);
|
||||||
|
|||||||
@ -11,7 +11,11 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
import type { GatewayError, LocalModelServerConfig } from "@/domain";
|
import type {
|
||||||
|
GatewayError,
|
||||||
|
LocalModelServerConfig,
|
||||||
|
ModelServerCommandPreview,
|
||||||
|
} from "@/domain";
|
||||||
import { useGateways } from "@/app/di";
|
import { useGateways } from "@/app/di";
|
||||||
|
|
||||||
/** What the model-server UI needs from this hook. */
|
/** What the model-server UI needs from this hook. */
|
||||||
@ -28,6 +32,14 @@ export interface ModelServersViewModel {
|
|||||||
save: (config: LocalModelServerConfig) => Promise<LocalModelServerConfig | null>;
|
save: (config: LocalModelServerConfig) => Promise<LocalModelServerConfig | null>;
|
||||||
/** Deletes a server by id; returns `true` on success. */
|
/** Deletes a server by id; returns `true` on success. */
|
||||||
remove: (serverId: string) => Promise<boolean>;
|
remove: (serverId: string) => Promise<boolean>;
|
||||||
|
/**
|
||||||
|
* Asks the backend to build the `llama-server` command line for a draft
|
||||||
|
* (never reconstructed client-side). Returns `null` when the draft is
|
||||||
|
* invalid — the editor shows a placeholder instead of a stale command.
|
||||||
|
*/
|
||||||
|
preview: (
|
||||||
|
config: LocalModelServerConfig,
|
||||||
|
) => Promise<ModelServerCommandPreview | null>;
|
||||||
/** Clears the current error banner. */
|
/** Clears the current error banner. */
|
||||||
clearError: () => void;
|
clearError: () => void;
|
||||||
}
|
}
|
||||||
@ -120,7 +132,20 @@ export function useModelServers(): ModelServersViewModel {
|
|||||||
[modelServer],
|
[modelServer],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const preview = useCallback(
|
||||||
|
async (config: LocalModelServerConfig) => {
|
||||||
|
try {
|
||||||
|
return await modelServer.previewModelServerCommand(config);
|
||||||
|
} catch {
|
||||||
|
// A preview is best-effort UI sugar: an invalid draft (e.g. no source)
|
||||||
|
// simply yields no command, without touching the CRUD error banner.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[modelServer],
|
||||||
|
);
|
||||||
|
|
||||||
const clearError = useCallback(() => setError(null), []);
|
const clearError = useCallback(() => setError(null), []);
|
||||||
|
|
||||||
return { servers, error, busy, reload, save, remove, clearError };
|
return { servers, error, busy, reload, save, remove, preview, clearError };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,6 +26,7 @@ import type {
|
|||||||
LayoutOperation,
|
LayoutOperation,
|
||||||
LayoutTree,
|
LayoutTree,
|
||||||
LocalModelServerConfig,
|
LocalModelServerConfig,
|
||||||
|
ModelServerCommandPreview,
|
||||||
Memory,
|
Memory,
|
||||||
MemoryIndexEntry,
|
MemoryIndexEntry,
|
||||||
MemoryLink,
|
MemoryLink,
|
||||||
@ -649,8 +650,9 @@ export interface CloneOpenCodeProfileFromSeedInput {
|
|||||||
/**
|
/**
|
||||||
* Local model servers (F35). CRUD over the global registry of declared
|
* Local model servers (F35). CRUD over the global registry of declared
|
||||||
* `llama.cpp` servers an OpenCode profile can bind to via
|
* `llama.cpp` servers an OpenCode profile can bind to via
|
||||||
* `OpenCodeConfig.localModelServerId`. Mirrors the three backend commands
|
* `OpenCodeConfig.localModelServerId`. Mirrors the backend commands
|
||||||
* (`list_model_servers`, `save_model_server`, `delete_model_server`).
|
* (`list_model_servers`, `save_model_server`, `delete_model_server`,
|
||||||
|
* `preview_model_server_command`).
|
||||||
*/
|
*/
|
||||||
export interface ModelServerGateway {
|
export interface ModelServerGateway {
|
||||||
/** Lists the declared local model servers. */
|
/** Lists the declared local model servers. */
|
||||||
@ -665,6 +667,15 @@ export interface ModelServerGateway {
|
|||||||
* `model_server_in_use` when a profile still references it.
|
* `model_server_in_use` when a profile still references it.
|
||||||
*/
|
*/
|
||||||
deleteModelServer(serverId: string): Promise<void>;
|
deleteModelServer(serverId: string): Promise<void>;
|
||||||
|
/**
|
||||||
|
* Builds the `llama-server` command line the backend would launch for the
|
||||||
|
* draft config, without persisting it. The backend is the sole authority on
|
||||||
|
* the argv (never reconstruct it client-side). Rejects when the config is
|
||||||
|
* invalid (e.g. `modelSource` missing).
|
||||||
|
*/
|
||||||
|
previewModelServerCommand(
|
||||||
|
config: LocalModelServerConfig,
|
||||||
|
): Promise<ModelServerCommandPreview>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Input for {@link EmbedderGateway.saveEmbedderProfile}. */
|
/** Input for {@link EmbedderGateway.saveEmbedderProfile}. */
|
||||||
|
|||||||
Reference in New Issue
Block a user