From bf53905a93255235ad3a84ca2978611b9df480f8 Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Tue, 22 Sep 2026 18:53:24 +0300 Subject: [PATCH] feat(#1219): support canonical model capability metadata (#1228) --- .../data/model/ModelInfoResponse.kt | 1 + .../hermescontrol/data/model/ModelOption.kt | 52 +++++++ .../data/remote/HermesApiService.kt | 4 +- .../ui/chat/ChatModelSwitchDelegate.kt | 38 ++++- .../m57/hermescontrol/ui/chat/ChatScreen.kt | 3 +- .../hermescontrol/ui/chat/ChatViewModel.kt | 4 +- .../components/KanbanModelOverrideEditor.kt | 5 +- .../m57/hermescontrol/ui/model/ModelScreen.kt | 15 +- .../hermescontrol/ui/model/ModelViewModel.kt | 65 ++++++++- .../components/ModelCapabilitySummary.kt | 36 +++++ .../ui/model/components/ModelPickerDialog.kt | 24 +--- .../ModelCapabilitiesSerializationTest.kt | 130 ++++++++++++++++++ .../ui/chat/ChatModelSwitchDelegateTest.kt | 37 +++++ .../ui/model/ModelViewModelTest.kt | 80 +++++++++++ 14 files changed, 451 insertions(+), 43 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/model/components/ModelCapabilitySummary.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/model/ModelCapabilitiesSerializationTest.kt diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/ModelInfoResponse.kt b/app/src/main/java/com/m57/hermescontrol/data/model/ModelInfoResponse.kt index 18351c08..bfb12b97 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/ModelInfoResponse.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/ModelInfoResponse.kt @@ -27,4 +27,5 @@ data class ModelInfoResponse( val config_context_length: Long? = null, /** Effective context window actually in use (max of auto/config + caps). */ val effective_context_length: Long? = null, + val capabilities: ModelCapabilities? = null, ) diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/ModelOption.kt b/app/src/main/java/com/m57/hermescontrol/data/model/ModelOption.kt index 1023322c..7745c462 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/ModelOption.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/ModelOption.kt @@ -26,8 +26,60 @@ data class ModelCapabilities( val fast: Boolean? = null, val reasoning: Boolean? = null, val can_disable_reasoning: Boolean? = null, + val supports_tools: Boolean? = null, + val supports_vision: Boolean? = null, + val supports_reasoning: Boolean? = null, + val context_window: Long? = null, + val max_output_tokens: Long? = null, + val model_family: String? = null, ) +val ModelCapabilities.reasoningSupport: Boolean? + get() = supports_reasoning ?: reasoning + +fun ModelCapabilities?.mergedWithCanonical(canonical: ModelCapabilities?): ModelCapabilities? { + if (canonical == null) return this + val catalog = this + return ModelCapabilities( + fast = catalog?.fast, + reasoning = catalog?.reasoning, + can_disable_reasoning = catalog?.can_disable_reasoning, + supports_tools = canonical.supports_tools ?: catalog?.supports_tools, + supports_vision = canonical.supports_vision ?: catalog?.supports_vision, + supports_reasoning = canonical.supports_reasoning ?: catalog?.supports_reasoning, + context_window = canonical.context_window ?: catalog?.context_window, + max_output_tokens = canonical.max_output_tokens ?: catalog?.max_output_tokens, + model_family = canonical.model_family ?: catalog?.model_family, + ) +} + +fun List.withModelInfoCapabilities(info: ModelInfoResponse?): List { + val providerSlug = info?.provider?.takeIf { it.isNotBlank() } ?: return this + val modelName = info.model?.takeIf { it.isNotBlank() } ?: return this + val canonical = info.capabilities ?: return this + var changed = false + val enriched = + map { provider -> + if ( + provider.slug.equals(providerSlug, ignoreCase = true) && + provider.models.orEmpty().contains(modelName) + ) { + changed = true + provider.copy( + capabilities = + provider.capabilities.orEmpty() + + ( + modelName to + (provider.capabilities?.get(modelName).mergedWithCanonical(canonical) ?: canonical) + ), + ) + } else { + provider + } + } + return if (changed) enriched else this +} + @Serializable data class PinnedModel( val providerSlug: String, diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt index 8be894c1..38d95160 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt @@ -300,7 +300,9 @@ interface HermesApiService : KanbanApiService { ): Response @GET("api/model/info") - suspend fun getModelInfo(): Response + suspend fun getModelInfo( + @Query("profile") profile: String? = null, + ): Response @GET("api/system/stats") suspend fun getSystemStats(): Response diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegate.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegate.kt index 1667f98b..78b75d7e 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegate.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegate.kt @@ -3,8 +3,11 @@ package com.m57.hermescontrol.ui.chat import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.local.DataScope import com.m57.hermescontrol.data.model.ModelCapabilities +import com.m57.hermescontrol.data.model.ModelInfoResponse import com.m57.hermescontrol.data.model.ModelProvider import com.m57.hermescontrol.data.model.PinnedModel +import com.m57.hermescontrol.data.model.mergedWithCanonical +import com.m57.hermescontrol.data.model.reasoningSupport import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.NetworkResult import com.m57.hermescontrol.data.remote.safeApiCall @@ -77,6 +80,9 @@ class ChatModelSwitchDelegate( private var cachedModelOptionsScope: DataScope? = dataScopeFlow.value private var modelOptionsJob: Job? = null private var modelOptionsJobScope: DataScope? = null + private var modelInfoCapabilities: ModelCapabilities? = null + private var modelInfoModel: String? = null + private var modelInfoScope: DataScope? = null fun attachScopeObserver(externalScope: CoroutineScope) { externalScope.launch { @@ -89,6 +95,9 @@ class ChatModelSwitchDelegate( cachedModelOptions = emptyList() cachedModelOptionsScope = newScope fastRejectedModels.clear() + modelInfoCapabilities = null + modelInfoModel = null + modelInfoScope = null if (modelOptionsJobScope != newScope) { modelOptionsJob?.cancel() @@ -469,6 +478,9 @@ class ChatModelSwitchDelegate( reasoningOpSequence++ reasoningJob?.cancel() reasoningJob = null + modelInfoCapabilities = null + modelInfoModel = null + modelInfoScope = null uiState.update { it.copy( pendingReasoningLevel = null, @@ -492,7 +504,7 @@ class ChatModelSwitchDelegate( // Capability validation if (enforceCapabilities) { val caps = uiState.value.currentModelCapabilities - if (caps?.reasoning == false) return + if (caps?.reasoningSupport == false) return if (level == "none" && caps?.can_disable_reasoning == false) return } @@ -578,10 +590,26 @@ class ChatModelSwitchDelegate( modelName: String, ): ModelCapabilities? { if (cachedModelOptionsScope != dataScopeFlow.value) return null - return cachedModelOptions - .find { it.slug == providerSlug } - ?.capabilities - ?.get(modelName) + val catalog = + cachedModelOptions + .find { it.slug == providerSlug } + ?.capabilities + ?.get(modelName) + val infoMatches = + modelInfoScope == dataScopeFlow.value && + (modelInfoModel == "$providerSlug/$modelName" || modelInfoModel?.substringAfter('/') == modelName) + val overlay = if (infoMatches) modelInfoCapabilities else null + return catalog.mergedWithCanonical(overlay) + } + + fun applyModelInfo( + info: ModelInfoResponse, + scope: DataScope?, + ) { + modelInfoCapabilities = info.capabilities + modelInfoModel = info.provider?.let { "$it/${info.model.orEmpty()}" } + modelInfoScope = scope + syncCurrentModelCapabilities() } fun getCurrentModelCapabilities(): ModelCapabilities? { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt index daa29ed1..330203a2 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt @@ -81,6 +81,7 @@ import com.m57.hermescontrol.NavigationController import com.m57.hermescontrol.R import com.m57.hermescontrol.data.model.Attachment import com.m57.hermescontrol.data.model.AttachmentSource +import com.m57.hermescontrol.data.model.reasoningSupport import com.m57.hermescontrol.data.ws.ConnectionStatus import com.m57.hermescontrol.data.ws.HermesWsClient import com.m57.hermescontrol.notification.NotificationHelper @@ -840,7 +841,7 @@ fun ChatScreen( onModelTap = { viewModel.openModelPicker() }, onReasoningTap = { level -> viewModel.setReasoningLevel(level) }, canDisableReasoning = state.currentModelCapabilities?.can_disable_reasoning, - supportsReasoning = state.currentModelCapabilities?.reasoning, + supportsReasoning = state.currentModelCapabilities?.reasoningSupport, fastMode = state.fastMode, fastSupported = state.currentModelCapabilities?.fast == true, isFastModeChanging = state.isFastModeChanging, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index 36e38374..ab556a0d 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -16,6 +16,7 @@ import com.m57.hermescontrol.data.model.SessionTimelineEntry import com.m57.hermescontrol.data.model.UsageSnapshotResponse import com.m57.hermescontrol.data.model.parseContextBreakdown import com.m57.hermescontrol.data.model.parseUsageSnapshot +import com.m57.hermescontrol.data.model.reasoningSupport import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.NetworkResult import com.m57.hermescontrol.data.remote.OkHttpProvider @@ -2344,7 +2345,7 @@ class ChatViewModel( if (value in REASONING_EFFORT_LEVELS) { if (parsed.scopeName != "global") { val caps = _uiState.value.currentModelCapabilities - if (caps?.reasoning == false) { + if (caps?.reasoningSupport == false) { addAssistantMessage("Reasoning is not supported for the current model.") return } @@ -3988,6 +3989,7 @@ class ChatViewModel( // another model (e.g. Solar, Gemini), the profile-level model/info describes // a different model and must never poison the session's context window. if (isMatchingModel(currentModel, info)) { + modelSwitchDelegate.applyModelInfo(info, AuthManager.currentDataScope()) info.effective_context_length ?: info.auto_context_length ?: info.config_context_length diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanModelOverrideEditor.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanModelOverrideEditor.kt index a96aff66..af8a6631 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanModelOverrideEditor.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanModelOverrideEditor.kt @@ -32,6 +32,7 @@ import androidx.compose.ui.unit.dp import com.m57.hermescontrol.R import com.m57.hermescontrol.data.model.ModelProvider import com.m57.hermescontrol.data.model.PinnedModel +import com.m57.hermescontrol.data.model.reasoningSupport import com.m57.hermescontrol.ui.kanban.KanbanModelOverride import com.m57.hermescontrol.ui.kanban.supportedKanbanReasoningEfforts import com.m57.hermescontrol.ui.model.components.ModelPickerDialog @@ -129,7 +130,7 @@ fun KanbanModelOverrideEditor( .firstOrNull { it.slug == override.provider } ?.capabilities ?.get(override.model) - val reasoningSupported = selectedCapabilities?.reasoning != false + val reasoningSupported = selectedCapabilities?.reasoningSupport != false val canDisableReasoning = selectedCapabilities?.can_disable_reasoning != false val supportedEfforts = supportedKanbanReasoningEfforts(reasoningSupported, canDisableReasoning) Row( @@ -180,7 +181,7 @@ fun KanbanModelOverrideEditor( override.copy( provider = provider, model = model, - effort = if (capabilities?.reasoning == false) "" else override.effort, + effort = if (capabilities?.reasoningSupport == false) "" else override.effort, ), ) showPickerDialog = false diff --git a/app/src/main/java/com/m57/hermescontrol/ui/model/ModelScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/model/ModelScreen.kt index aed0803d..7a993d89 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/model/ModelScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/model/ModelScreen.kt @@ -67,6 +67,7 @@ import com.m57.hermescontrol.ui.common.SearchBar import com.m57.hermescontrol.ui.common.SkeletonListState import com.m57.hermescontrol.ui.common.ToastEffect import com.m57.hermescontrol.ui.model.components.MoaConfigDialog +import com.m57.hermescontrol.ui.model.components.ModelCapabilitySummary import com.m57.hermescontrol.ui.model.components.ModelPickerDialog @OptIn(ExperimentalMaterial3Api::class) @@ -874,19 +875,7 @@ private fun ProviderCard( // the row vertically instead of pushing // the check/pin buttons off-screen. ) - val hint = - when { - caps?.reasoning == false -> "no reasoning" - caps?.can_disable_reasoning == false -> "reasoning always on" - else -> null - } - if (hint != null) { - Text( - text = hint, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + ModelCapabilitySummary(caps) } Row( verticalAlignment = Alignment.CenterVertically, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/model/ModelViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/model/ModelViewModel.kt index 7d99de54..8e3a549b 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/model/ModelViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/model/ModelViewModel.kt @@ -3,20 +3,25 @@ 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.local.DataScope 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.ModelInfoResponse import com.m57.hermescontrol.data.model.ModelProvider import com.m57.hermescontrol.data.model.PinnedModel import com.m57.hermescontrol.data.model.ProfileInfo import com.m57.hermescontrol.data.model.UpdateProfileModelRequest +import com.m57.hermescontrol.data.model.withModelInfoCapabilities import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.NetworkResult import com.m57.hermescontrol.data.remote.safeApiCall import com.m57.hermescontrol.data.ws.ModelCatalogStore import com.m57.hermescontrol.data.ws.ModelOptionsRepository import com.m57.hermescontrol.ui.common.ToastHost +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -45,6 +50,7 @@ data class ModelUiState( val isLoading: Boolean = false, val catalogLoading: Boolean = false, val providers: List = emptyList(), + val modelInfo: ModelInfoResponse? = null, val activeProfile: ProfileInfo? = null, val errorMessage: String? = null, val toastMessage: String? = null, @@ -71,12 +77,19 @@ data class ModelUiState( class ModelViewModel( private val catalogStore: ModelCatalogStore = ModelCatalogStore.shared, + private val getCurrentScope: () -> DataScope? = { AuthManager.currentDataScope() }, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, + private val getModelInfoCall: suspend (String?) -> NetworkResult = { profile -> + safeApiCall { ApiClient.hermesApi.getModelInfo(profile) } + }, ) : ViewModel(), ToastHost { constructor(repository: ModelOptionsRepository) : this(ModelCatalogStore(repository = repository)) private val _uiState = MutableStateFlow(ModelUiState()) val uiState: StateFlow = _uiState.asStateFlow() + private var modelInfoJob: Job? = null + private var modelInfoScope: DataScope? = null init { _uiState.update { it.copy(pinnedModels = AuthManager.getPinnedModels()) } @@ -84,9 +97,18 @@ class ModelViewModel( catalogStore.state.collect { catalogState -> // Empty state is meaningful: it is how ModelCatalogStore // invalidates the previous DataScope. Never ignore it. + val observedScope = catalogState.scope ?: getCurrentScope() + val scopeChanged = modelInfoScope != observedScope + if (scopeChanged) { + modelInfoJob?.cancel() + modelInfoJob = null + modelInfoScope = observedScope + } _uiState.update { + val modelInfo = if (scopeChanged) null else it.modelInfo it.copy( - providers = catalogState.providers, + providers = catalogState.providers.withModelInfoCapabilities(modelInfo), + modelInfo = modelInfo, catalogLoading = catalogState.isRefreshing, ) } @@ -96,6 +118,7 @@ class ModelViewModel( fun loadAll(refresh: Boolean = false) { _uiState.update { it.copy(isLoading = true, catalogLoading = true, errorMessage = null) } + loadModelCapabilities() viewModelScope.launch { // Phase 1: Launch fast lightweight calls first (profiles, aux, moa) val activeProfileDeferred = @@ -199,6 +222,7 @@ class ModelViewModel( /** Fast refresh after model assignments/updates without re-fetching full provider catalog. */ fun loadQuick() { + loadModelCapabilities(clearExisting = true) viewModelScope.launch { val activeProfileDeferred = async(Dispatchers.IO) { @@ -254,6 +278,45 @@ class ModelViewModel( } } + fun loadModelCapabilities(clearExisting: Boolean = false) { + val requestScope = getCurrentScope() + if (modelInfoScope != requestScope) { + modelInfoJob?.cancel() + modelInfoScope = requestScope + _uiState.update { + it.copy( + providers = catalogStore.state.value.providers, + modelInfo = null, + ) + } + } else if (clearExisting) { + _uiState.update { + it.copy( + providers = catalogStore.state.value.providers, + modelInfo = null, + ) + } + } + + modelInfoJob?.cancel() + if (requestScope == null) return + modelInfoJob = + viewModelScope.launch(ioDispatcher) { + val result = getModelInfoCall(requestScope.activeProfileId) + if (getCurrentScope() != requestScope) return@launch + if (result is NetworkResult.Success) { + _uiState.update { + it.copy( + providers = + catalogStore.state.value.providers + .withModelInfoCapabilities(result.data), + modelInfo = result.data, + ) + } + } + } + } + /** Open the main model picker dialog. */ fun openMainModelPicker() { _uiState.update { it.copy(showMainModelPicker = true) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/model/components/ModelCapabilitySummary.kt b/app/src/main/java/com/m57/hermescontrol/ui/model/components/ModelCapabilitySummary.kt new file mode 100644 index 00000000..88343b48 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/model/components/ModelCapabilitySummary.kt @@ -0,0 +1,36 @@ +package com.m57.hermescontrol.ui.model.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.data.model.ModelCapabilities +import com.m57.hermescontrol.data.model.reasoningSupport + +@Composable +fun ModelCapabilitySummary(capabilities: ModelCapabilities?) { + if (capabilities == null) return + val labels = + buildList { + capabilities.supports_tools?.let { add(if (it) "Tools" else "No tools") } + capabilities.supports_vision?.let { add(if (it) "Vision" else "No vision") } + capabilities.reasoningSupport?.let { add(if (it) "Reasoning" else "no reasoning") } + if (capabilities.reasoningSupport == true && capabilities.can_disable_reasoning == false) { + add("reasoning always on") + } + capabilities.context_window?.let { add("${it / 1000}K ctx") } + capabilities.max_output_tokens?.let { add("${it / 1000}K output") } + capabilities.model_family?.takeIf { it.isNotBlank() }?.let { add(it) } + } + if (labels.isNotEmpty()) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + text = labels.joinToString(" ยท "), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/model/components/ModelPickerDialog.kt b/app/src/main/java/com/m57/hermescontrol/ui/model/components/ModelPickerDialog.kt index 4bb075fb..52b4c0ca 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/model/components/ModelPickerDialog.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/model/components/ModelPickerDialog.kt @@ -40,6 +40,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.ModelCapabilities import com.m57.hermescontrol.data.model.ModelProvider import com.m57.hermescontrol.data.model.PinnedModel import com.m57.hermescontrol.ui.common.LoadingState @@ -213,8 +214,7 @@ fun ModelPickerDialog( null }, onClick = { onSelect(pinned.providerSlug, pinned.modelName) }, - canDisableReasoning = caps?.can_disable_reasoning, - supportsReasoning = caps?.reasoning, + capabilities = caps, ) } } @@ -294,8 +294,7 @@ fun ModelPickerDialog( null }, onClick = { onSelect(provider.slug, model) }, - canDisableReasoning = caps?.can_disable_reasoning, - supportsReasoning = caps?.reasoning, + capabilities = caps, ) } } @@ -315,8 +314,7 @@ private fun ModelItemCard( onPinToggle: (() -> Unit)?, onClick: () -> Unit, modifier: Modifier = Modifier, - canDisableReasoning: Boolean? = null, - supportsReasoning: Boolean? = null, + capabilities: ModelCapabilities? = null, ) { Surface( modifier = @@ -348,19 +346,7 @@ private fun ModelItemCard( // No maxLines/ellipsis: a long model name wraps so the full // name stays readable while the pin button keeps its spot. ) - val hint = - when { - supportsReasoning == false -> "no reasoning" - canDisableReasoning == false -> "reasoning always on" - else -> null - } - if (hint != null) { - Text( - text = hint, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + ModelCapabilitySummary(capabilities) } if (onPinToggle != null) { IconButton( diff --git a/app/src/test/java/com/m57/hermescontrol/data/model/ModelCapabilitiesSerializationTest.kt b/app/src/test/java/com/m57/hermescontrol/data/model/ModelCapabilitiesSerializationTest.kt new file mode 100644 index 00000000..b8a1d860 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/model/ModelCapabilitiesSerializationTest.kt @@ -0,0 +1,130 @@ +package com.m57.hermescontrol.data.model + +import com.m57.hermescontrol.data.remote.OkHttpProvider +import kotlinx.serialization.decodeFromString +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class ModelCapabilitiesSerializationTest { + private val json = OkHttpProvider.json + + @Test + fun fullCapabilities_decodeAllNullableFields() { + val value = + json.decodeFromString( + """ + {"model":"gpt-5","provider":"openai","capabilities":{ + "supports_tools":false,"supports_vision":true,"supports_reasoning":true, + "context_window":128000,"max_output_tokens":4096,"model_family":"gpt-5" + }} + """.trimIndent(), + ) + + assertEquals(false, value.capabilities?.supports_tools) + assertEquals(true, value.capabilities?.supports_vision) + assertEquals(true, value.capabilities?.reasoningSupport) + assertEquals(128000L, value.capabilities?.context_window) + assertEquals(4096L, value.capabilities?.max_output_tokens) + assertEquals("gpt-5", value.capabilities?.model_family) + } + + @Test + fun partialMissingAndNullCapabilities_preserveUnknown() { + val partial = json.decodeFromString("""{"capabilities":{"supports_vision":false}}""") + val missing = json.decodeFromString("{}") + val explicitNull = json.decodeFromString("""{"capabilities":null}""") + + assertEquals(false, partial.capabilities?.supports_vision) + assertNull(partial.capabilities?.supports_tools) + assertNull(partial.capabilities?.reasoningSupport) + assertNull(missing.capabilities) + assertNull(explicitNull.capabilities) + } + + @Test + fun canonicalReasoning_takesPrecedenceOverLegacyField() { + val value = + json.decodeFromString( + """ + {"providers":[{"slug":"test","name":"Test","capabilities":{ + "model":{"reasoning":true,"supports_reasoning":false} + }}]} + """.trimIndent(), + ) + + assertEquals( + false, + value.providers + .single() + .capabilities + ?.get("model") + ?.reasoningSupport, + ) + } + + @Test + fun modelInfoCapabilities_enrichOnlyMatchingCatalogModel() { + val catalog = + listOf( + ModelProvider( + slug = "openai", + name = "OpenAI", + models = listOf("gpt-5", "gpt-4o"), + capabilities = + mapOf( + "gpt-5" to ModelCapabilities(fast = true, reasoning = true), + "gpt-4o" to ModelCapabilities(fast = false, reasoning = false), + ), + ), + ) + val info = + ModelInfoResponse( + model = "gpt-5", + provider = "openai", + capabilities = + ModelCapabilities( + supports_tools = false, + supports_vision = true, + supports_reasoning = true, + context_window = 128000, + max_output_tokens = 4096, + model_family = "gpt-5", + ), + ) + + val enriched = catalog.withModelInfoCapabilities(info) + + val active = enriched.single().capabilities?.get("gpt-5") + assertEquals(true, active?.fast) + assertEquals(false, active?.supports_tools) + assertEquals(true, active?.supports_vision) + assertEquals(128000L, active?.context_window) + assertEquals("gpt-5", active?.model_family) + assertEquals( + ModelCapabilities(fast = false, reasoning = false), + enriched.single().capabilities?.get("gpt-4o"), + ) + } + + @Test + fun modelInfoCapabilities_doNotBleedIntoDifferentProviderOrModel() { + val catalog = + listOf( + ModelProvider( + slug = "openai", + name = "OpenAI", + models = listOf("gpt-5"), + capabilities = mapOf("gpt-5" to ModelCapabilities(reasoning = true)), + ), + ) + val otherProfileInfo = + ModelInfoResponse( + model = "claude-sonnet-4-5", + provider = "anthropic", + capabilities = ModelCapabilities(supports_vision = true), + ) + + assertEquals(catalog, catalog.withModelInfoCapabilities(otherProfileInfo)) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegateTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegateTest.kt index f8ee5ecb..b4d7da88 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegateTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegateTest.kt @@ -2,6 +2,7 @@ package com.m57.hermescontrol.ui.chat import com.m57.hermescontrol.data.local.DataScope import com.m57.hermescontrol.data.model.ModelCapabilities +import com.m57.hermescontrol.data.model.ModelInfoResponse import com.m57.hermescontrol.data.model.ModelOptionsResponse import com.m57.hermescontrol.data.model.ModelProvider import com.m57.hermescontrol.data.model.PinnedModel @@ -11,6 +12,7 @@ import com.m57.hermescontrol.data.ws.WsMethods import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceUntilIdle @@ -175,6 +177,41 @@ class ChatModelSwitchDelegateTest { scopeObserverJob.cancel() } + @Test + fun modelInfoCapabilities_mergeWithCatalogAndClearOnScopeSwitch() = + testScope.runTest { + val scopeObserverJob = kotlinx.coroutines.Job() + val observerScope = kotlinx.coroutines.CoroutineScope(testDispatcher + scopeObserverJob) + delegate.attachScopeObserver(observerScope) + delegate.preloadModelOptions() + advanceUntilIdle() + uiState.update { it.copy(currentSessionModel = "openai/gpt-4o") } + + delegate.applyModelInfo( + ModelInfoResponse( + provider = "openai", + model = "gpt-4o", + capabilities = + ModelCapabilities( + supports_tools = false, + supports_vision = true, + supports_reasoning = true, + ), + ), + initialDataScope, + ) + + assertEquals(true, uiState.value.currentModelCapabilities?.fast) + assertEquals(false, uiState.value.currentModelCapabilities?.supports_tools) + assertEquals(true, uiState.value.currentModelCapabilities?.supports_vision) + + dataScope.value = initialDataScope.copy(activeProfileId = "work") + runCurrent() + + assertNull(uiState.value.currentModelCapabilities) + scopeObserverJob.cancel() + } + @Test fun closeAndReopenDuringPreload_doesNotDuplicateOrReopenAfterDismiss() = testScope.runTest { diff --git a/app/src/test/java/com/m57/hermescontrol/ui/model/ModelViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/model/ModelViewModelTest.kt index 19fce4d9..5358e1d3 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/model/ModelViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/model/ModelViewModelTest.kt @@ -2,6 +2,8 @@ package com.m57.hermescontrol.ui.model import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.local.DataScope +import com.m57.hermescontrol.data.model.ModelCapabilities +import com.m57.hermescontrol.data.model.ModelInfoResponse import com.m57.hermescontrol.data.model.ModelOptionsResponse import com.m57.hermescontrol.data.model.ModelProvider import com.m57.hermescontrol.data.model.PinnedModel @@ -206,6 +208,84 @@ class ModelViewModelTest { ) } + @Test + fun `model info enriches active model and is cleared on profile switch`() = + runTest(testDispatcher) { + var currentScope = + DataScope( + connectionProfileId = "conn-1", + baseUrl = "http://localhost:9119", + activeProfileId = "default", + inMemoryAuthGeneration = 1L, + ) + val response = + ModelOptionsResponse( + providers = + listOf( + ModelProvider( + slug = "openai", + name = "OpenAI", + models = listOf("gpt-5"), + capabilities = mapOf("gpt-5" to ModelCapabilities(reasoning = true)), + ), + ), + ) + val store = + ModelCatalogStore( + repository = + ModelOptionsRepository( + connected = { false }, + rest = { NetworkResult.Success(response) }, + ), + getCurrentScope = { currentScope }, + scope = this, + ) + val requestedProfiles = mutableListOf() + val viewModel = + ModelViewModel( + catalogStore = store, + getCurrentScope = { currentScope }, + ioDispatcher = testDispatcher, + getModelInfoCall = { profile -> + requestedProfiles += profile + NetworkResult.Success( + ModelInfoResponse( + provider = "openai", + model = "gpt-5", + capabilities = ModelCapabilities(supports_vision = true), + ), + ) + }, + ) + + store.ensureLoaded() + viewModel.loadModelCapabilities() + advanceUntilIdle() + + assertEquals(listOf("default"), requestedProfiles) + assertEquals( + true, + viewModel.uiState.value.providers + .single() + .capabilities + ?.get("gpt-5") + ?.supports_vision, + ) + + currentScope = currentScope.copy(activeProfileId = "work") + store.onScopeChanged(currentScope) + store.ensureLoaded() + advanceUntilIdle() + + assertNull( + viewModel.uiState.value.providers + .single() + .capabilities + ?.get("gpt-5") + ?.supports_vision, + ) + } + @Test fun `pinModel shows toast when max cap reached`() { val viewModel = createViewModel()