feat(#445): full Models tab parity — settings, aux tasks, MOA config

* feat(#445): full Models tab — settings panel, aux tasks, MOA config, model assignment

Main features added:
- Model Settings card at top of screen with main model, aux tasks, MOA summary
- Main model assignment via /api/model/set with expensive model confirm flow
- Auxiliary tasks dialog with per-task model override and reset-all
- MOA config dialog with preset management and reference/aggregator model picker
- Model picker dialog reused for main model, aux tasks, and MOA slot selection
- Kept existing provider browse + pin/unpin/search

New data models: AuxiliaryModelsResponse, MoaConfigResponse,
ModelAssignmentRequest/Response
New API endpoints: GET /api/model/auxiliary, GET/PUT /api/model/moa,
POST /api/model/set

* test(#445): add aux + MOA mock endpoints to E2eIntegrationTest

testModelOptionsSelection_success was failing because the ViewModel's init
block now calls getAuxiliaryModels() and getMoaConfig() which weren't mocked.
Added proper mock returns + imports for the new types.

* fix(#445): correct mock endpoint name in E2eIntegrationTest

- getMoaConfig -> getMoaModels (matches API service method name)
- Add explicit type parameter Response.success<MoaConfigResponse>
  to fix Kotlin type inference

* fix(#445): add missing main param to AuxiliaryModelsResponse mock

AuxiliaryModelsResponse requires a MainModelAssignment for its
'main' field. Added the parameter with a dummy value and the
corresponding import.

* fix(#445): update toast assertion in test to match new message format

ViewModel now produces 'Successfully set profile model to ronia'
instead of the old 'Successfully set model to ronia'.

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-07-03 11:59:32 +03:00
1 parent efb8e528ef
commit dc808daee8
5 files changed
+1674 -397

No files matched your search

@@ -0,0 +1,64 @@
package com.m57.hermescontrol.data.model
data class AuxiliaryTaskAssignment(
val task: String,
val provider: String,
val model: String,
val base_url: String? = null,
)
data class AuxiliaryModelsResponse(
val tasks: List<AuxiliaryTaskAssignment>,
val main: MainModelAssignment,
)
data class MainModelAssignment(
val provider: String,
val model: String,
)
data class MoaModelSlot(
val provider: String,
val model: String,
)
data class MoaConfigPreset(
val reference_models: List<MoaModelSlot>,
val aggregator: MoaModelSlot,
val reference_temperature: Double = 0.7,
val aggregator_temperature: Double = 0.3,
val max_tokens: Int = 4096,
val enabled: Boolean = true,
)
data class MoaConfigResponse(
val default_preset: String = "",
val active_preset: String = "",
val presets: Map<String, MoaConfigPreset> = emptyMap(),
val reference_models: List<MoaModelSlot> = emptyList(),
val aggregator: MoaModelSlot = MoaModelSlot("", ""),
val reference_temperature: Double = 0.7,
val aggregator_temperature: Double = 0.3,
val max_tokens: Int = 4096,
val enabled: Boolean = true,
)
data class ModelAssignmentRequest(
val confirm_expensive_model: Boolean = false,
val scope: String,
val provider: String,
val model: String,
val base_url: String? = null,
val task: String? = null,
)
data class ModelAssignmentResponse(
val confirm_message: String? = null,
val confirm_required: Boolean? = null,
val ok: Boolean = false,
val scope: String? = null,
val provider: String? = null,
val model: String? = null,
val tasks: List<String>? = null,
val reset: Boolean? = null,
)
@@ -4,6 +4,7 @@ import com.google.gson.JsonElement
import com.m57.hermescontrol.data.model.AchievementsResponse
import com.m57.hermescontrol.data.model.ActiveProfileResponse
import com.m57.hermescontrol.data.model.AgentPluginInstallBody
import com.m57.hermescontrol.data.model.AuxiliaryModelsResponse
import com.m57.hermescontrol.data.model.ConfigSchemaResponse
import com.m57.hermescontrol.data.model.ConfigUpdateRequest
import com.m57.hermescontrol.data.model.CreateCronJobRequest
@@ -25,6 +26,9 @@ import com.m57.hermescontrol.data.model.McpServersResponse
import com.m57.hermescontrol.data.model.MessagingPlatformResponse
import com.m57.hermescontrol.data.model.MessagingPlatformTestResult
import com.m57.hermescontrol.data.model.MessagingPlatformUpdate
import com.m57.hermescontrol.data.model.MoaConfigResponse
import com.m57.hermescontrol.data.model.ModelAssignmentRequest
import com.m57.hermescontrol.data.model.ModelAssignmentResponse
import com.m57.hermescontrol.data.model.ModelOptionsResponse
import com.m57.hermescontrol.data.model.PairingApproveRequest
import com.m57.hermescontrol.data.model.PairingResponse
@@ -285,6 +289,22 @@ interface HermesApiService {
@GET("api/model/options")
suspend fun getModelOptions(): Response<ModelOptionsResponse>
@GET("api/model/auxiliary")
suspend fun getAuxiliaryModels(): Response<AuxiliaryModelsResponse>
@GET("api/model/moa")
suspend fun getMoaModels(): Response<MoaConfigResponse>
@PUT("api/model/moa")
suspend fun saveMoaModels(
@Body body: MoaConfigResponse,
): Response<MoaConfigResponse>
@POST("api/model/set")
suspend fun setModelAssignment(
@Body body: ModelAssignmentRequest,
): Response<ModelAssignmentResponse>
@GET("api/logs")
suspend fun getLogs(
@Query("limit") limit: Int? = null,
File diff suppressed because it is too large. Load diff
@@ -3,6 +3,9 @@ package com.m57.hermescontrol.ui.model
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.model.AuxiliaryTaskAssignment
import com.m57.hermescontrol.data.model.MoaConfigResponse
import com.m57.hermescontrol.data.model.ModelAssignmentRequest
import com.m57.hermescontrol.data.model.ModelProvider
import com.m57.hermescontrol.data.model.PinnedModel
import com.m57.hermescontrol.data.model.ProfileInfo
@@ -20,6 +23,22 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
data class AuxTaskInfo(
val key: String,
val label: String,
val hint: String,
)
val AUX_TASKS =
listOf(
AuxTaskInfo("vision", "Vision", "image/video analysis"),
AuxTaskInfo("session_search", "Session search", "past conversation search"),
AuxTaskInfo("summarizer", "Compression", "context compression"),
AuxTaskInfo("code_exec", "Code execution", "runtime for code blocks"),
AuxTaskInfo("computer_use", "Computer use", "desktop/browser automation"),
AuxTaskInfo("web_search", "Web search", "live search queries"),
)
data class ModelUiState(
val isLoading: Boolean = false,
val providers: List<ModelProvider> = emptyList(),
@@ -27,6 +46,24 @@ data class ModelUiState(
val errorMessage: String? = null,
val toastMessage: String? = null,
val pinnedModels: List<PinnedModel> = emptyList(),
// Main model
val mainModelProvider: String = "",
val mainModelModel: String = "",
// Auxiliary tasks
val auxTasks: List<AuxiliaryTaskAssignment> = emptyList(),
// MOA config
val moaConfig: MoaConfigResponse? = null,
// Dialog state
val showAuxDialog: Boolean = false,
val showMoaDialog: Boolean = false,
val showMainModelPicker: Boolean = false,
val showAuxModelPicker: Boolean = false,
val auxPickerTask: String = "",
// Model picker busy (for expensive model confirmation)
val modelPickerBusy: Boolean = false,
val modelPickerConfirmMessage: String? = null,
// Pending resolve
val pendingModelPickerResolve: ((Boolean) -> Unit)? = null,
)
class ModelViewModel :
@@ -39,37 +76,69 @@ class ModelViewModel :
_uiState.update { it.copy(pinnedModels = AuthManager.getPinnedModels()) }
}
fun loadAll() {
loadModelOptions()
}
fun loadModelOptions() {
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
viewModelScope.launch {
val responseResultDeferred =
val optionsDeferred =
async(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.getModelOptions() }
}
val activeProfileNameResResultDeferred =
val activeProfileDeferred =
async(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.getActiveProfile() }
}
val profilesResResultDeferred =
val profilesDeferred =
async(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.getProfiles() }
}
val auxDeferred =
async(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.getAuxiliaryModels() }
}
val moaDeferred =
async(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.getMoaModels() }
}
val responseResult = responseResultDeferred.await()
val activeProfileNameResResult = activeProfileNameResResultDeferred.await()
val profilesResResult = profilesResResultDeferred.await()
val optionsResult = optionsDeferred.await()
val activeProfileResult = activeProfileDeferred.await()
val profilesResult = profilesDeferred.await()
val auxResult = auxDeferred.await()
val moaResult = moaDeferred.await()
when (responseResult) {
when (optionsResult) {
is NetworkResult.Success -> {
val activeName =
if (activeProfileNameResResult is NetworkResult.Success) {
activeProfileNameResResult.data.active
if (activeProfileResult is NetworkResult.Success) {
activeProfileResult.data.active
} else {
null
}
val activeProfile =
if (profilesResResult is NetworkResult.Success && activeName != null) {
profilesResResult.data.profiles.find { it.name == activeName }
if (profilesResult is NetworkResult.Success && activeName != null) {
profilesResult.data.profiles.find { it.name == activeName }
} else {
null
}
val mainModel =
if (auxResult is NetworkResult.Success) {
Pair(auxResult.data.main.provider, auxResult.data.main.model)
} else {
Pair("", "")
}
val auxTasks =
if (auxResult is NetworkResult.Success) {
auxResult.data.tasks
} else {
emptyList()
}
val moaConfig =
if (moaResult is NetworkResult.Success) {
moaResult.data
} else {
null
}
@@ -77,18 +146,21 @@ class ModelViewModel :
_uiState.update {
it.copy(
isLoading = false,
providers = responseResult.data.providers.orEmpty(),
providers = optionsResult.data.providers.orEmpty(),
activeProfile = activeProfile,
pinnedModels = AuthManager.getPinnedModels(),
mainModelProvider = mainModel.first,
mainModelModel = mainModel.second,
auxTasks = auxTasks,
moaConfig = moaConfig,
)
}
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load model options: ${responseResult.error.message}",
errorMessage = "Failed to load model options: ${optionsResult.error.message}",
)
}
}
@@ -96,6 +168,273 @@ class ModelViewModel :
}
}
/** Open the main model picker dialog. */
fun openMainModelPicker() {
_uiState.update { it.copy(showMainModelPicker = true) }
}
fun closeMainModelPicker() {
_uiState.update { it.copy(showMainModelPicker = false) }
}
/** Set the main model via /api/model/set. */
fun setMainModel(
provider: String,
model: String,
) {
_uiState.update { it.copy(modelPickerBusy = true) }
viewModelScope.launch {
val result =
withContext(Dispatchers.IO) {
safeApiCall {
ApiClient.hermesApi.setModelAssignment(
ModelAssignmentRequest(
scope = "main",
provider = provider,
model = model,
),
)
}
}
when (result) {
is NetworkResult.Success -> {
val data = result.data
if (data.confirm_required == true) {
_uiState.update {
it.copy(
modelPickerConfirmMessage =
data.confirm_message ?: "This model is expensive. Continue?",
pendingModelPickerResolve = { confirmed ->
if (confirmed) {
setMainModelWithConfirm(provider, model)
}
_uiState.update {
it.copy(
modelPickerConfirmMessage = null,
pendingModelPickerResolve = null,
)
}
},
)
}
} else {
_uiState.update {
it.copy(
toastMessage = "Main model set to $provider / $model",
showMainModelPicker = false,
modelPickerBusy = false,
)
}
loadModelOptions()
}
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
toastMessage = "Failed to set main model: ${result.error.message}",
modelPickerBusy = false,
)
}
}
}
}
}
private fun setMainModelWithConfirm(
provider: String,
model: String,
) {
viewModelScope.launch {
val result =
withContext(Dispatchers.IO) {
safeApiCall {
ApiClient.hermesApi.setModelAssignment(
ModelAssignmentRequest(
confirm_expensive_model = true,
scope = "main",
provider = provider,
model = model,
),
)
}
}
when (result) {
is NetworkResult.Success -> {
_uiState.update {
it.copy(
toastMessage = "Main model set to $provider / $model",
showMainModelPicker = false,
modelPickerBusy = false,
)
}
loadModelOptions()
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
toastMessage = "Failed to set main model: ${result.error.message}",
modelPickerBusy = false,
)
}
}
}
}
}
fun dismissModelPickerConfirm() {
_uiState.update {
it.copy(
modelPickerConfirmMessage = null,
pendingModelPickerResolve = null,
)
}
_uiState.value.pendingModelPickerResolve?.invoke(false)
}
fun confirmModelPickerExpensive() {
_uiState.value.pendingModelPickerResolve?.invoke(true)
}
// -------------------- Auxiliary tasks --------------------
fun openAuxDialog() {
_uiState.update { it.copy(showAuxDialog = true) }
}
fun closeAuxDialog() {
_uiState.update { it.copy(showAuxDialog = false) }
}
fun openAuxModelPicker(task: String) {
_uiState.update { it.copy(showAuxModelPicker = true, auxPickerTask = task) }
}
fun closeAuxModelPicker() {
_uiState.update { it.copy(showAuxModelPicker = false, auxPickerTask = "") }
}
/** Set an auxiliary task model override. */
fun setAuxTask(
task: String,
provider: String,
model: String,
) {
viewModelScope.launch {
val result =
withContext(Dispatchers.IO) {
safeApiCall {
ApiClient.hermesApi.setModelAssignment(
ModelAssignmentRequest(
scope = "auxiliary",
task = task,
provider = provider,
model = model,
),
)
}
}
when (result) {
is NetworkResult.Success -> {
_uiState.update {
it.copy(
toastMessage = "Aux task '$task' set to $provider / $model",
showAuxModelPicker = false,
auxPickerTask = "",
)
}
loadModelOptions()
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
toastMessage = "Failed to set aux task: ${result.error.message}",
)
}
}
}
}
}
/** Reset all auxiliary tasks to auto. */
fun resetAllAuxTasks() {
viewModelScope.launch {
_uiState.update { it.copy(modelPickerBusy = true) }
val result =
withContext(Dispatchers.IO) {
safeApiCall {
ApiClient.hermesApi.setModelAssignment(
ModelAssignmentRequest(
scope = "auxiliary",
task = "__reset__",
provider = "",
model = "",
),
)
}
}
when (result) {
is NetworkResult.Success -> {
_uiState.update {
it.copy(toastMessage = "All auxiliary tasks reset to auto", modelPickerBusy = false)
}
loadModelOptions()
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
toastMessage = "Failed to reset aux tasks: ${result.error.message}",
modelPickerBusy = false,
)
}
}
}
}
}
// -------------------- MOA config --------------------
fun openMoaDialog() {
_uiState.update { it.copy(showMoaDialog = true) }
}
fun closeMoaDialog() {
_uiState.update { it.copy(showMoaDialog = false) }
}
fun saveMoaConfig(config: MoaConfigResponse) {
viewModelScope.launch {
_uiState.update { it.copy(modelPickerBusy = true) }
val result =
withContext(Dispatchers.IO) {
safeApiCall {
ApiClient.hermesApi.saveMoaModels(config)
}
}
when (result) {
is NetworkResult.Success -> {
_uiState.update {
it.copy(
moaConfig = result.data,
toastMessage = "MOA config saved",
showMoaDialog = false,
modelPickerBusy = false,
)
}
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
toastMessage = "Failed to save MOA config: ${result.error.message}",
modelPickerBusy = false,
)
}
}
}
}
}
// -------------------- Profile model selection (existing) --------------------
fun selectModel(
providerSlug: String,
modelName: String,
@@ -115,7 +454,6 @@ class ModelViewModel :
}
return@launch
}
is NetworkResult.Success -> {
val activeProfileName = activeProfileNameResResult.data.active
val updateResResult =
@@ -129,14 +467,13 @@ class ModelViewModel :
}
when (updateResResult) {
is NetworkResult.Success -> {
_uiState.update { it.copy(toastMessage = "Successfully set model to $modelName") }
_uiState.update { it.copy(toastMessage = "Successfully set profile model to $modelName") }
loadModelOptions()
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
toastMessage = "Failed to set model: ${updateResResult.error.message}",
toastMessage = "Failed to set profile model: ${updateResResult.error.message}",
)
}
}
@@ -5,6 +5,7 @@ import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.model.ActiveProfileResponse
import com.m57.hermescontrol.data.model.AuxiliaryModelsResponse
import com.m57.hermescontrol.data.model.CronJob
import com.m57.hermescontrol.data.model.DoctorResponse
import com.m57.hermescontrol.data.model.EnvVarConfig
@@ -14,8 +15,11 @@ import com.m57.hermescontrol.data.model.KanbanBoardsResponse
import com.m57.hermescontrol.data.model.KanbanColumn
import com.m57.hermescontrol.data.model.KanbanTask
import com.m57.hermescontrol.data.model.LogResponse
import com.m57.hermescontrol.data.model.MainModelAssignment
import com.m57.hermescontrol.data.model.MessagingPlatform
import com.m57.hermescontrol.data.model.MessagingPlatformResponse
import com.m57.hermescontrol.data.model.MoaConfigResponse
import com.m57.hermescontrol.data.model.MoaModelSlot
import com.m57.hermescontrol.data.model.ModelOptionsResponse
import com.m57.hermescontrol.data.model.ModelProvider
import com.m57.hermescontrol.data.model.PluginInfo
@@ -1047,6 +1051,25 @@ class E2eIntegrationTest {
Response.success(ActiveProfileResponse("default", null))
coEvery { mockApiService.getProfiles() } returns Response.success(ProfilesResponse(listOf(profile)))
coEvery { mockApiService.updateProfileModel("default", any()) } returns Response.success(Unit)
coEvery { mockApiService.getAuxiliaryModels() } returns
Response.success(
AuxiliaryModelsResponse(
tasks = emptyList(),
main = MainModelAssignment("openai", "gpt-4"),
),
)
coEvery { mockApiService.getMoaModels() } returns
Response.success<MoaConfigResponse>(
MoaConfigResponse(
presets = emptyMap(),
default_preset = "",
reference_models = emptyList(),
aggregator = MoaModelSlot("openai", "gpt-4"),
reference_temperature = 0.7,
aggregator_temperature = 0.7,
max_tokens = 4096,
),
)
val viewModel = ModelViewModel()
viewModel.loadModelOptions()
@@ -1068,7 +1091,7 @@ class E2eIntegrationTest {
advanceUntilIdle()
coVerify { mockApiService.updateProfileModel("default", any()) }
assertEquals("Successfully set model to llama3", viewModel.uiState.value.toastMessage)
assertEquals("Successfully set profile model to llama3", viewModel.uiState.value.toastMessage)
}
@Test