feat(model-server): backend modèles locaux — serveur llama.cpp intégré (#35) et profils OpenCode locaux multiples (#36)

Sprint « Modeles locaux », couche backend (domain/application/infra/app-tauri).

#35 — Serveur de modèle local intégré (llama.cpp) :
- domain: model_server.rs (agrégat + statut), ports ModelServerProbe /
  ManagedProcess / ModelServerRuntime / ModelServerRegistry, événements
  model_server_status_changed et agent_launch_failed.
- application: use case EnsureLocalModelServer branché sur LaunchAgent.
- infrastructure: adapters HttpOpenAiCompatibleProbe, LlamaCppRuntime,
  LocalManagedProcess, FsModelServerRegistry.
- app-tauri: DTO plat LocalModelServerConfigDto, commandes
  list/save/delete_model_server avec garde model_server_in_use, wiring.

#36 — Profils OpenCode locaux multiples :
- domain: VO LocalModelServerId, OpenCodeConfig.local_model_server_id.
- application: use case CloneOpenCodeProfileFromSeed.
- app-tauri: commande clone_opencode_profile_from_seed, DTO/wiring.

Tests verts (exécution réelle) : domain+application OK, app-tauri
dto_model_servers 3/3 et dto_profiles 10/10, infra model_server 2/2,
application model_server+profile_usecases 19/19, cargo build workspace Finished.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-11 15:50:08 +02:00
parent 2e98f1fbb9
commit b82ac76f8b
24 changed files with 3147 additions and 94 deletions

View File

@ -0,0 +1,111 @@
//! DTO contract tests for local model-server IPC.
use app_tauri_lib::dto::{
ModelServerConfigDto, ModelServerKindDto, SaveModelServerRequestDto, StopPolicyDto,
save_model_server_input,
};
use domain::model_server::{
ExecutablePath, LocalModelRef, LocalModelServerConfig, LocalModelServerKind, ModelPath,
ModelServerEndpoint,
};
use domain::{LocalModelServerId, StopPolicy};
use serde_json::json;
use uuid::Uuid;
fn sid(n: u128) -> LocalModelServerId {
LocalModelServerId::from_uuid(Uuid::from_u128(n))
}
fn domain_config(id: LocalModelServerId, model_id: &str) -> LocalModelServerConfig {
LocalModelServerConfig::new(
id,
LocalModelServerKind::LlamaCpp,
"Local Qwen",
ModelServerEndpoint::new("http://localhost:8080", 8080).unwrap(),
LocalModelRef::new(
model_id,
"Qwen",
Some(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()],
true,
StopPolicy::StopOnAppExit,
)
.unwrap()
}
#[test]
fn model_server_dto_serialises_flat_camelcase_wire_shape() {
let dto = ModelServerConfigDto::from_domain(domain_config(sid(35), "internal-model-id"));
let value = serde_json::to_value(dto).unwrap();
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["servedModelName"], "qwen3-coder");
assert_eq!(value["binaryPath"], "/usr/bin/llama-server");
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");
}
#[test]
fn model_server_dto_deserialises_flat_wire_shape_and_generates_model_id() {
let raw = json!({
"id": sid(36).to_string(),
"kind": "llamaCpp",
"name": "Local Qwen",
"baseURL": "http://localhost:8081",
"port": 8081,
"modelPath": "/models/qwen.gguf",
"servedModelName": "qwen3-coder",
"binaryPath": "/usr/bin/llama-server",
"args": ["--ctx-size", "4096"],
"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.id, sid(36));
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"
);
assert_eq!(input.config.model.served_name, "qwen3-coder");
assert_eq!(input.config.model.label, "qwen3-coder");
assert!(!input.config.model.id.is_empty());
}
#[test]
fn model_server_dto_preserves_existing_internal_model_id_on_upsert() {
let existing = domain_config(sid(37), "stable-internal-id");
let dto = ModelServerConfigDto {
id: sid(37).to_string(),
kind: ModelServerKindDto::LlamaCpp,
name: "Updated Qwen".to_owned(),
base_url: "http://localhost:8082/v1".to_owned(),
port: 8082,
model_path: Some("/models/qwen-updated.gguf".to_owned()),
served_model_name: "qwen3-coder-updated".to_owned(),
binary_path: Some("/usr/bin/llama-server".to_owned()),
args: Vec::new(),
auto_start: true,
stop_policy: StopPolicyDto::StopOnAppExit,
};
let config = dto.into_domain(Some(&existing)).unwrap();
assert_eq!(config.model.id, "stable-internal-id");
assert_eq!(config.model.served_name, "qwen3-coder-updated");
}

View File

@ -3,15 +3,16 @@
//! error behaviour.
use app_tauri_lib::dto::{
parse_delete_profile, parse_profile_id, ConfigureProfilesRequestDto, DetectProfilesRequestDto,
DetectProfilesResponseDto, FirstRunStateDto, ProfileListDto, SaveProfileRequestDto,
parse_delete_profile, parse_profile_id, CloneOpenCodeProfileFromSeedRequestDto,
ConfigureProfilesRequestDto, DetectProfilesRequestDto, DetectProfilesResponseDto,
FirstRunStateDto, ProfileListDto, SaveProfileRequestDto,
};
use application::{
ConfigureProfilesInput, DetectProfilesInput, DetectProfilesOutput, FirstRunStateOutput,
ProfileAvailability, SaveProfileInput,
CloneOpenCodeProfileFromSeedInput, ConfigureProfilesInput, DetectProfilesInput,
DetectProfilesOutput, FirstRunStateOutput, ProfileAvailability, SaveProfileInput,
};
use domain::ids::ProfileId;
use domain::profile::{AgentProfile, ContextInjection};
use domain::ids::{LocalModelServerId, ProfileId};
use domain::profile::{AgentProfile, ContextInjection, OpenCodeConfig};
use serde_json::json;
use uuid::Uuid;
@ -94,6 +95,47 @@ fn save_request_deserialises_profile() {
assert!(input.profile.detect.is_none());
}
#[test]
fn clone_opencode_profile_from_seed_request_deserialises_camelcase_config() {
let server_id = LocalModelServerId::from_uuid(Uuid::from_u128(35));
let raw = json!({
"name": "Local Qwen",
"opencode": {
"baseURL": "http://localhost:8081/v1",
"model": "qwen3-coder-70b",
"reasoning": false,
"attachment": true,
"localModelServerId": server_id.to_string()
}
});
let dto: CloneOpenCodeProfileFromSeedRequestDto = serde_json::from_value(raw).unwrap();
let input: CloneOpenCodeProfileFromSeedInput = dto.into();
assert_eq!(input.name.as_deref(), Some("Local Qwen"));
let opencode = input.opencode.expect("config override");
assert_eq!(opencode.base_url, "http://localhost:8081/v1");
assert_eq!(opencode.api_key, None);
assert_eq!(opencode.model, "qwen3-coder-70b");
assert_eq!(opencode.reasoning, Some(false));
assert_eq!(opencode.attachment, Some(true));
assert_eq!(opencode.local_model_server_id, Some(server_id));
}
#[test]
fn opencode_config_dto_omits_local_model_server_id_when_none() {
let config = OpenCodeConfig::new(
"http://localhost:8080/v1",
None,
"qwen3-coder-30b",
None,
None,
)
.unwrap();
let value = serde_json::to_value(&config).unwrap();
assert!(value.get("localModelServerId").is_none());
}
#[test]
fn configure_request_deserialises_profiles() {
let raw = json!({ "profiles": [] });