feat(model-server): source Hugging Face (-hf) + options structurées llama.cpp + migration store V2
Backend du support natif d'une source modèle Hugging Face en alternative au
chemin .gguf local pour le serveur llama.cpp intégré, et refonte des options.
- domaine : ModelSource {LocalPath|HuggingFace} + HfModelRef,
LlamaCppOptions {host,gpu_layers,context_size,jinja}, invariant auto_start
exigeant une source, validate_free_args (rejet des flags réservés).
- infra : build_argv partagé avec build_spawn_spec, émission -hf vs --model,
ordre argv figé ; migration model-servers.json V1 -> V2.
- app-tauri : DTO V2 (modelSource, compat modelPath, conflit = INVALID),
commande preview_model_server_command.
QA : cargo test ciblés verts (domain 7, application 8, infra model_server 4,
app-tauri dto 5). Échecs des suites complètes infra/app-tauri environnementaux
(sandbox bind/socket), hors périmètre.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -26,12 +26,14 @@ use application::{
|
||||
UnassignSkillFromAgentInput, UpdateAgentContextInput, UpdateAgentPermissionsInput,
|
||||
UpdateMemoryInput, UpdateProjectContextInput, UpdateProjectPermissionsInput, UpdateSkillInput,
|
||||
};
|
||||
use domain::ports::ModelServerRuntime;
|
||||
use domain::ports::PtyHandle;
|
||||
|
||||
use crate::dto::{
|
||||
parse_agent_id, parse_close_terminal, parse_delete_profile, parse_layout_id, parse_memory_slug,
|
||||
parse_node_id, parse_profile_id, parse_project_id, parse_session_id, parse_skill_id,
|
||||
parse_task_id, parse_template_id, parse_ticket_id, AgentDriftListDto, AgentDto, AgentListDto,
|
||||
model_server_config_domain, parse_agent_id, parse_close_terminal, parse_delete_profile,
|
||||
parse_layout_id, parse_memory_slug, parse_model_server_id, parse_node_id, parse_profile_id,
|
||||
parse_project_id, parse_session_id, parse_skill_id, parse_task_id, parse_template_id,
|
||||
parse_ticket_id, save_model_server_input, AgentDriftListDto, AgentDto, AgentListDto,
|
||||
AssignSkillRequestDto, AttachLiveAgentRequestDto, AttachLiveAgentResponseDto,
|
||||
BackgroundTaskDto, ChangeAgentProfileDto, ChangeAgentProfileRequestDto,
|
||||
CloneOpenCodeProfileFromSeedRequestDto, ConfigureProfilesRequestDto, ConversationDetailsDto,
|
||||
@ -45,19 +47,19 @@ use crate::dto::{
|
||||
GraphCommitListDto, HealthRequestDto, HealthResponseDto, InspectConversationRequestDto,
|
||||
InterruptAgentRequestDto, LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto,
|
||||
LiveAgentListDto, MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto,
|
||||
ModelServerConfigDto, ModelServerConfigListDto, OpenTerminalRequestDto, ProfileDto,
|
||||
ProfileListDto, ProjectDto, ProjectListDto, ProjectPermissionsDto, ProjectWorkStateDto,
|
||||
ReadAgentContextResponseDto, ReadConversationPageRequestDto, ReattachChatDto,
|
||||
ReattachResultDto, RecallMemoryRequestDto, RenameLayoutRequestDto, ReplyChunk,
|
||||
ResizeTerminalRequestDto, ResolveAgentPermissionsRequestDto, ResumableAgentListDto,
|
||||
SaveEmbedderProfileRequestDto, SaveModelServerRequestDto, SaveProfileRequestDto,
|
||||
SetActiveLayoutRequestDto, SetActiveLayoutResultDto, SkillDto, SkillListDto,
|
||||
StopLiveAgentRequestDto, StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto,
|
||||
SyncResultDto, TemplateDto, TemplateListDto, TerminalClosedDto, TerminalSessionDto,
|
||||
TurnPageDto, UnassignSkillRequestDto, UpdateAgentContextRequestDto,
|
||||
UpdateAgentPermissionsRequestDto, UpdateMemoryRequestDto, UpdateProjectContextRequestDto,
|
||||
UpdateProjectPermissionsRequestDto, UpdateSkillRequestDto, UpdateTemplateRequestDto,
|
||||
WriteTerminalRequestDto, parse_model_server_id, save_model_server_input,
|
||||
ModelServerConfigDto, ModelServerConfigListDto, OpenTerminalRequestDto,
|
||||
PreviewModelServerCommandDto, ProfileDto, ProfileListDto, ProjectDto, ProjectListDto,
|
||||
ProjectPermissionsDto, ProjectWorkStateDto, ReadAgentContextResponseDto,
|
||||
ReadConversationPageRequestDto, ReattachChatDto, ReattachResultDto, RecallMemoryRequestDto,
|
||||
RenameLayoutRequestDto, ReplyChunk, ResizeTerminalRequestDto,
|
||||
ResolveAgentPermissionsRequestDto, ResumableAgentListDto, SaveEmbedderProfileRequestDto,
|
||||
SaveModelServerRequestDto, SaveProfileRequestDto, SetActiveLayoutRequestDto,
|
||||
SetActiveLayoutResultDto, SkillDto, SkillListDto, StopLiveAgentRequestDto,
|
||||
StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto,
|
||||
TemplateListDto, TerminalClosedDto, TerminalSessionDto, TurnPageDto, UnassignSkillRequestDto,
|
||||
UpdateAgentContextRequestDto, UpdateAgentPermissionsRequestDto, UpdateMemoryRequestDto,
|
||||
UpdateProjectContextRequestDto, UpdateProjectPermissionsRequestDto, UpdateSkillRequestDto,
|
||||
UpdateTemplateRequestDto, WriteTerminalRequestDto,
|
||||
};
|
||||
use crate::pty::{PtyBridge, PtyChunk};
|
||||
use crate::state::AppState;
|
||||
@ -900,6 +902,28 @@ pub async fn save_model_server(
|
||||
.map_err(ErrorDto::from)
|
||||
}
|
||||
|
||||
/// `preview_model_server_command` — build the llama.cpp argv without persisting.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an [`ErrorDto`] when the DTO or runtime invocation is invalid.
|
||||
#[tauri::command]
|
||||
pub fn preview_model_server_command(
|
||||
config: ModelServerConfigDto,
|
||||
) -> Result<PreviewModelServerCommandDto, ErrorDto> {
|
||||
let config = model_server_config_domain(config, None)?;
|
||||
let argv = infrastructure::LlamaCppRuntime::new()
|
||||
.build_argv(&config)
|
||||
.map_err(|err| ErrorDto {
|
||||
code: "INVALID".to_owned(),
|
||||
message: err.to_string(),
|
||||
})?;
|
||||
Ok(PreviewModelServerCommandDto {
|
||||
display: display_command(&argv.command, &argv.args),
|
||||
command: argv.command,
|
||||
args: argv.args,
|
||||
})
|
||||
}
|
||||
|
||||
/// `delete_model_server` — delete a local model server config when unused.
|
||||
///
|
||||
/// # Errors
|
||||
@ -924,6 +948,27 @@ fn model_server_command_error(err: AppError) -> ErrorDto {
|
||||
}
|
||||
}
|
||||
|
||||
fn display_command(command: &str, args: &[String]) -> String {
|
||||
std::iter::once(command)
|
||||
.chain(args.iter().map(String::as_str))
|
||||
.map(shell_escape)
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
fn shell_escape(value: &str) -> String {
|
||||
if value.is_empty() {
|
||||
return "''".to_owned();
|
||||
}
|
||||
if value
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '/' | '.' | '_' | '-' | ':' | '='))
|
||||
{
|
||||
return value.to_owned();
|
||||
}
|
||||
format!("'{}'", value.replace('\'', "'\\''"))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Embedder profiles & engines (LOT C2 — §14.5.3)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@ -913,8 +913,8 @@ impl From<FirstRunStateOutput> for FirstRunStateDto {
|
||||
|
||||
use application::{ListModelServersOutput, SaveModelServerInput, SaveModelServerOutput};
|
||||
use domain::model_server::{
|
||||
ExecutablePath, LocalModelRef, LocalModelServerConfig, LocalModelServerKind, ModelPath,
|
||||
ModelServerEndpoint,
|
||||
ExecutablePath, HfModelRef, LlamaCppOptions, LocalModelRef, LocalModelServerConfig,
|
||||
LocalModelServerKind, ModelPath, ModelServerEndpoint, ModelSource,
|
||||
};
|
||||
use domain::{LocalModelServerId, StopPolicy};
|
||||
|
||||
@ -974,6 +974,44 @@ impl From<StopPolicyDto> for StopPolicy {
|
||||
}
|
||||
}
|
||||
|
||||
/// Model source on the IPC wire.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", tag = "type")]
|
||||
pub enum ModelSourceDto {
|
||||
/// Local `.gguf` path.
|
||||
LocalPath {
|
||||
/// Absolute local path.
|
||||
path: String,
|
||||
},
|
||||
/// Hugging Face `namespace/repo[:quant]` reference.
|
||||
HuggingFace {
|
||||
/// Repository reference.
|
||||
repo: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl From<ModelSource> for ModelSourceDto {
|
||||
fn from(source: ModelSource) -> Self {
|
||||
match source {
|
||||
ModelSource::LocalPath { path } => Self::LocalPath { path: path.0 },
|
||||
ModelSource::HuggingFace { repo } => Self::HuggingFace { repo: repo.0 },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ModelSourceDto {
|
||||
fn into_domain(self) -> Result<ModelSource, ErrorDto> {
|
||||
match self {
|
||||
Self::LocalPath { path } => Ok(ModelSource::LocalPath {
|
||||
path: ModelPath::new(path).map_err(invalid_domain_error)?,
|
||||
}),
|
||||
Self::HuggingFace { repo } => Ok(ModelSource::HuggingFace {
|
||||
repo: HfModelRef::new(repo).map_err(invalid_domain_error)?,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A local model-server config crossing the wire.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@ -989,14 +1027,29 @@ pub struct ModelServerConfigDto {
|
||||
pub base_url: String,
|
||||
/// TCP port.
|
||||
pub port: u16,
|
||||
/// Optional explicit `.gguf` model path.
|
||||
/// Optional explicit model source.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub model_source: Option<ModelSourceDto>,
|
||||
/// Legacy V1 input alias for `modelSource:{type:"localPath",path}`.
|
||||
#[serde(default, skip_serializing)]
|
||||
pub model_path: Option<String>,
|
||||
/// Served model name exposed to OpenCode.
|
||||
pub served_model_name: String,
|
||||
/// Optional explicit `llama-server` executable path.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub binary_path: Option<String>,
|
||||
/// llama.cpp host passed to `--host`.
|
||||
#[serde(default = "default_llamacpp_host")]
|
||||
pub host: String,
|
||||
/// llama.cpp GPU layers passed to `-ngl`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub gpu_layers: Option<u32>,
|
||||
/// llama.cpp context size passed to `-c`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub context_size: Option<u32>,
|
||||
/// Whether to pass `--jinja`.
|
||||
#[serde(default)]
|
||||
pub jinja: bool,
|
||||
/// Extra argv entries.
|
||||
#[serde(default)]
|
||||
pub args: Vec<String>,
|
||||
@ -1016,12 +1069,14 @@ impl ModelServerConfigDto {
|
||||
name: config.name,
|
||||
base_url: config.endpoint.base_url,
|
||||
port: config.endpoint.port,
|
||||
model_path: config
|
||||
.model
|
||||
.path
|
||||
.map(|path| path.as_str().to_owned()),
|
||||
model_source: config.model.source.map(Into::into),
|
||||
model_path: None,
|
||||
served_model_name: config.model.served_name,
|
||||
binary_path: config.binary.map(|binary| binary.as_str().to_owned()),
|
||||
host: config.options.host,
|
||||
gpu_layers: config.options.gpu_layers,
|
||||
context_size: config.options.context_size,
|
||||
jinja: config.options.jinja,
|
||||
args: config.args,
|
||||
auto_start: config.auto_start,
|
||||
stop_policy: config.stop_policy.into(),
|
||||
@ -1040,26 +1095,21 @@ impl ModelServerConfigDto {
|
||||
let server_id = parse_model_server_id(&self.id)?;
|
||||
let endpoint = ModelServerEndpoint::new(self.base_url.clone(), self.port)
|
||||
.map_err(invalid_domain_error)?;
|
||||
let model_path = optional_non_empty(self.model_path)
|
||||
.map(ModelPath::new)
|
||||
.transpose()
|
||||
.map_err(invalid_domain_error)?;
|
||||
let source = resolve_model_source(self.model_source, self.model_path)?;
|
||||
let model_id = existing
|
||||
.filter(|config| config.id == server_id)
|
||||
.map(|config| config.model.id.clone())
|
||||
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
|
||||
let label = non_empty_or_fallback(&self.served_model_name, &self.name);
|
||||
let model = LocalModelRef::new(
|
||||
model_id,
|
||||
label,
|
||||
model_path,
|
||||
self.served_model_name.clone(),
|
||||
)
|
||||
.map_err(invalid_domain_error)?;
|
||||
let model = LocalModelRef::new(model_id, label, source, self.served_model_name.clone())
|
||||
.map_err(invalid_domain_error)?;
|
||||
let binary = optional_non_empty(self.binary_path)
|
||||
.map(ExecutablePath::new)
|
||||
.transpose()
|
||||
.map_err(invalid_domain_error)?;
|
||||
let options =
|
||||
LlamaCppOptions::new(self.host, self.gpu_layers, self.context_size, self.jinja)
|
||||
.map_err(invalid_domain_error)?;
|
||||
LocalModelServerConfig::new(
|
||||
server_id,
|
||||
self.kind.into(),
|
||||
@ -1067,6 +1117,7 @@ impl ModelServerConfigDto {
|
||||
endpoint,
|
||||
model,
|
||||
binary,
|
||||
options,
|
||||
self.args,
|
||||
self.auto_start,
|
||||
self.stop_policy.into(),
|
||||
@ -1075,6 +1126,18 @@ impl ModelServerConfigDto {
|
||||
}
|
||||
}
|
||||
|
||||
/// Response DTO for `preview_model_server_command`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PreviewModelServerCommandDto {
|
||||
/// Executable command.
|
||||
pub command: String,
|
||||
/// Arguments without shell parsing.
|
||||
pub args: Vec<String>,
|
||||
/// Human-readable escaped command line.
|
||||
pub display: String,
|
||||
}
|
||||
|
||||
/// List response for local model servers.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(transparent)]
|
||||
@ -1132,11 +1195,52 @@ pub fn save_model_server_input(
|
||||
})
|
||||
}
|
||||
|
||||
/// Converts a model-server DTO to domain using the same path as save.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`ErrorDto`] if DTO-to-domain validation fails.
|
||||
pub fn model_server_config_domain(
|
||||
config: ModelServerConfigDto,
|
||||
existing: Option<&LocalModelServerConfig>,
|
||||
) -> Result<LocalModelServerConfig, ErrorDto> {
|
||||
config.into_domain(existing)
|
||||
}
|
||||
|
||||
fn resolve_model_source(
|
||||
model_source: Option<ModelSourceDto>,
|
||||
model_path: Option<String>,
|
||||
) -> Result<Option<ModelSource>, ErrorDto> {
|
||||
let legacy_path = optional_non_empty(model_path);
|
||||
match (model_source, legacy_path) {
|
||||
(None, None) => Ok(None),
|
||||
(None, Some(path)) => Ok(Some(ModelSource::LocalPath {
|
||||
path: ModelPath::new(path).map_err(invalid_domain_error)?,
|
||||
})),
|
||||
(Some(source), None) => Ok(Some(source.into_domain()?)),
|
||||
(Some(source), Some(path)) => {
|
||||
let domain_source = source.into_domain()?;
|
||||
match &domain_source {
|
||||
ModelSource::LocalPath { path: source_path } if source_path.as_str() == path => {
|
||||
Ok(Some(domain_source))
|
||||
}
|
||||
_ => Err(ErrorDto {
|
||||
code: "INVALID".to_owned(),
|
||||
message: "modelPath and modelSource differ".to_owned(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn optional_non_empty(raw: Option<String>) -> Option<String> {
|
||||
raw.map(|value| value.trim().to_owned())
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn default_llamacpp_host() -> String {
|
||||
"127.0.0.1".to_owned()
|
||||
}
|
||||
|
||||
fn non_empty_or_fallback(primary: &str, fallback: &str) -> String {
|
||||
let primary = primary.trim();
|
||||
if primary.is_empty() {
|
||||
|
||||
@ -164,6 +164,7 @@ pub fn run() {
|
||||
commands::configure_profiles,
|
||||
commands::list_model_servers,
|
||||
commands::save_model_server,
|
||||
commands::preview_model_server_command,
|
||||
commands::delete_model_server,
|
||||
commands::list_embedder_profiles,
|
||||
commands::save_embedder_profile,
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
//! DTO contract tests for local model-server IPC.
|
||||
|
||||
use app_tauri_lib::dto::{
|
||||
ModelServerConfigDto, ModelServerKindDto, SaveModelServerRequestDto, StopPolicyDto,
|
||||
save_model_server_input,
|
||||
save_model_server_input, ModelServerConfigDto, ModelServerKindDto, ModelSourceDto,
|
||||
SaveModelServerRequestDto, StopPolicyDto,
|
||||
};
|
||||
use domain::model_server::{
|
||||
ExecutablePath, LocalModelRef, LocalModelServerConfig, LocalModelServerKind, ModelPath,
|
||||
ModelServerEndpoint,
|
||||
ExecutablePath, LlamaCppOptions, LocalModelRef, LocalModelServerConfig, LocalModelServerKind,
|
||||
ModelPath, ModelServerEndpoint, ModelSource,
|
||||
};
|
||||
use domain::{LocalModelServerId, StopPolicy};
|
||||
use serde_json::json;
|
||||
@ -25,12 +25,15 @@ fn domain_config(id: LocalModelServerId, model_id: &str) -> LocalModelServerConf
|
||||
LocalModelRef::new(
|
||||
model_id,
|
||||
"Qwen",
|
||||
Some(ModelPath::new("/models/qwen.gguf").unwrap()),
|
||||
Some(ModelSource::LocalPath {
|
||||
path: ModelPath::new("/models/qwen.gguf").unwrap(),
|
||||
}),
|
||||
"qwen3-coder",
|
||||
)
|
||||
.unwrap(),
|
||||
Some(ExecutablePath::new("/usr/bin/llama-server").unwrap()),
|
||||
vec!["--ctx-size".to_owned(), "4096".to_owned()],
|
||||
LlamaCppOptions::new("127.0.0.1", Some(35), Some(4096), true).unwrap(),
|
||||
vec!["--threads".to_owned(), "8".to_owned()],
|
||||
true,
|
||||
StopPolicy::StopOnAppExit,
|
||||
)
|
||||
@ -46,12 +49,21 @@ fn model_server_dto_serialises_flat_camelcase_wire_shape() {
|
||||
assert_eq!(value["id"], sid(35).to_string());
|
||||
assert_eq!(value["kind"], "llamaCpp");
|
||||
assert_eq!(value["baseURL"], "http://localhost:8080/v1");
|
||||
assert_eq!(value["modelPath"], "/models/qwen.gguf");
|
||||
assert_eq!(value["modelSource"]["type"], "localPath");
|
||||
assert_eq!(value["modelSource"]["path"], "/models/qwen.gguf");
|
||||
assert!(value.get("modelPath").is_none());
|
||||
assert_eq!(value["servedModelName"], "qwen3-coder");
|
||||
assert_eq!(value["binaryPath"], "/usr/bin/llama-server");
|
||||
assert_eq!(value["host"], "127.0.0.1");
|
||||
assert_eq!(value["gpuLayers"], 35);
|
||||
assert_eq!(value["contextSize"], 4096);
|
||||
assert_eq!(value["jinja"], true);
|
||||
assert_eq!(value["stopPolicy"], "stopOnAppExit");
|
||||
assert!(value.get("model").is_none(), "domain model must not leak");
|
||||
assert!(value.get("endpoint").is_none(), "domain endpoint must not leak");
|
||||
assert!(
|
||||
value.get("endpoint").is_none(),
|
||||
"domain endpoint must not leak"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -65,7 +77,9 @@ fn model_server_dto_deserialises_flat_wire_shape_and_generates_model_id() {
|
||||
"modelPath": "/models/qwen.gguf",
|
||||
"servedModelName": "qwen3-coder",
|
||||
"binaryPath": "/usr/bin/llama-server",
|
||||
"args": ["--ctx-size", "4096"],
|
||||
"gpuLayers": 35,
|
||||
"contextSize": 4096,
|
||||
"args": ["--threads", "8"],
|
||||
"autoStart": true,
|
||||
"stopPolicy": "stopOnAppExit"
|
||||
});
|
||||
@ -79,9 +93,15 @@ fn model_server_dto_deserialises_flat_wire_shape_and_generates_model_id() {
|
||||
assert_eq!(input.config.kind, LocalModelServerKind::LlamaCpp);
|
||||
assert_eq!(input.config.endpoint.base_url, "http://localhost:8081/v1");
|
||||
assert_eq!(
|
||||
input.config.model.path.unwrap().as_str(),
|
||||
"/models/qwen.gguf"
|
||||
input.config.model.source,
|
||||
Some(ModelSource::LocalPath {
|
||||
path: ModelPath::new("/models/qwen.gguf").unwrap()
|
||||
})
|
||||
);
|
||||
assert_eq!(input.config.options.gpu_layers, Some(35));
|
||||
assert_eq!(input.config.options.context_size, Some(4096));
|
||||
assert_eq!(input.config.options.host, "127.0.0.1");
|
||||
assert!(!input.config.options.jinja);
|
||||
assert_eq!(input.config.model.served_name, "qwen3-coder");
|
||||
assert_eq!(input.config.model.label, "qwen3-coder");
|
||||
assert!(!input.config.model.id.is_empty());
|
||||
@ -96,9 +116,16 @@ fn model_server_dto_preserves_existing_internal_model_id_on_upsert() {
|
||||
name: "Updated Qwen".to_owned(),
|
||||
base_url: "http://localhost:8082/v1".to_owned(),
|
||||
port: 8082,
|
||||
model_path: Some("/models/qwen-updated.gguf".to_owned()),
|
||||
model_source: Some(ModelSourceDto::LocalPath {
|
||||
path: "/models/qwen-updated.gguf".to_owned(),
|
||||
}),
|
||||
model_path: None,
|
||||
served_model_name: "qwen3-coder-updated".to_owned(),
|
||||
binary_path: Some("/usr/bin/llama-server".to_owned()),
|
||||
host: "127.0.0.1".to_owned(),
|
||||
gpu_layers: None,
|
||||
context_size: None,
|
||||
jinja: false,
|
||||
args: Vec::new(),
|
||||
auto_start: true,
|
||||
stop_policy: StopPolicyDto::StopOnAppExit,
|
||||
@ -109,3 +136,66 @@ fn model_server_dto_preserves_existing_internal_model_id_on_upsert() {
|
||||
assert_eq!(config.model.id, "stable-internal-id");
|
||||
assert_eq!(config.model.served_name, "qwen3-coder-updated");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_server_dto_roundtrips_v2_huggingface_source() {
|
||||
let raw = json!({
|
||||
"id": sid(38).to_string(),
|
||||
"kind": "llamaCpp",
|
||||
"name": "HF Qwen",
|
||||
"baseURL": "http://localhost:8083",
|
||||
"port": 8083,
|
||||
"modelSource": { "type": "huggingFace", "repo": "Qwen/Qwen3-Coder:Q4_K_M" },
|
||||
"servedModelName": "qwen3-coder",
|
||||
"binaryPath": "/usr/bin/llama-server",
|
||||
"host": "127.0.0.1",
|
||||
"jinja": false,
|
||||
"args": [],
|
||||
"autoStart": true,
|
||||
"stopPolicy": "stopOnAppExit"
|
||||
});
|
||||
|
||||
let request = SaveModelServerRequestDto {
|
||||
config: serde_json::from_value(raw).unwrap(),
|
||||
};
|
||||
let input = save_model_server_input(request, None).unwrap();
|
||||
assert_eq!(
|
||||
input.config.model.source,
|
||||
Some(ModelSource::HuggingFace {
|
||||
repo: domain::model_server::HfModelRef::new("Qwen/Qwen3-Coder:Q4_K_M").unwrap()
|
||||
})
|
||||
);
|
||||
|
||||
let value = serde_json::to_value(ModelServerConfigDto::from_domain(input.config)).unwrap();
|
||||
assert_eq!(value["modelSource"]["type"], "huggingFace");
|
||||
assert_eq!(value["modelSource"]["repo"], "Qwen/Qwen3-Coder:Q4_K_M");
|
||||
assert!(value.get("modelPath").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_server_dto_rejects_conflicting_model_path_alias() {
|
||||
let raw = json!({
|
||||
"id": sid(39).to_string(),
|
||||
"kind": "llamaCpp",
|
||||
"name": "Local Qwen",
|
||||
"baseURL": "http://localhost:8084",
|
||||
"port": 8084,
|
||||
"modelSource": { "type": "localPath", "path": "/models/a.gguf" },
|
||||
"modelPath": "/models/b.gguf",
|
||||
"servedModelName": "qwen3-coder",
|
||||
"host": "127.0.0.1",
|
||||
"jinja": false,
|
||||
"args": [],
|
||||
"autoStart": true,
|
||||
"stopPolicy": "stopOnAppExit"
|
||||
});
|
||||
|
||||
let request = SaveModelServerRequestDto {
|
||||
config: serde_json::from_value(raw).unwrap(),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
save_model_server_input(request, None).unwrap_err().code,
|
||||
"INVALID"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user