feat(#1219): support canonical model capability metadata (#1228)

This commit is contained in:
m57 authored and GitHub committed 2026-09-22 18:53:24 +03:00
1 parent 16967c7e91
commit bf53905a93
14 files changed
+451 -43

No files matched your search

@@ -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,
)
@@ -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<ModelProvider>.withModelInfoCapabilities(info: ModelInfoResponse?): List<ModelProvider> {
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,
@@ -300,7 +300,9 @@ interface HermesApiService : KanbanApiService {
): Response<SessionInfo>
@GET("api/model/info")
suspend fun getModelInfo(): Response<ModelInfoResponse>
suspend fun getModelInfo(
@Query("profile") profile: String? = null,
): Response<ModelInfoResponse>
@GET("api/system/stats")
suspend fun getSystemStats(): Response<SystemStatsResponse>
@@ -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? {
@@ -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,
@@ -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
@@ -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
@@ -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,
@@ -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<ModelProvider> = 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<ModelInfoResponse> = { profile ->
safeApiCall { ApiClient.hermesApi.getModelInfo(profile) }
},
) : ViewModel(),
ToastHost {
constructor(repository: ModelOptionsRepository) : this(ModelCatalogStore(repository = repository))
private val _uiState = MutableStateFlow(ModelUiState())
val uiState: StateFlow<ModelUiState> = _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) }
@@ -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,
)
}
}
}
@@ -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(