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 13b46171..1023322c 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 @@ -18,6 +18,14 @@ data class ModelProvider( val authenticated: Boolean? = null, val auth_type: String? = null, val warning: String? = null, + val capabilities: Map? = null, +) + +@Serializable +data class ModelCapabilities( + val fast: Boolean? = null, + val reasoning: Boolean? = null, + val can_disable_reasoning: Boolean? = null, ) @Serializable 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 0be31712..dceaf4cb 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 @@ -760,6 +760,8 @@ fun ChatScreen( reasoningLevel = state.reasoningLevel, onModelTap = { viewModel.openModelPicker() }, onReasoningTap = { level -> viewModel.setReasoningLevel(level) }, + canDisableReasoning = state.currentModelCapabilities?.can_disable_reasoning, + supportsReasoning = state.currentModelCapabilities?.reasoning, ) } 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 62dfbfd4..f9645cb4 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 @@ -12,6 +12,7 @@ import com.m57.hermescontrol.data.local.HermesDatabase import com.m57.hermescontrol.data.local.SlashUsageStore import com.m57.hermescontrol.data.model.Attachment import com.m57.hermescontrol.data.model.AttachmentSource +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.data.model.SessionMessage @@ -291,6 +292,9 @@ data class ChatUiState( val modelPickerLoading: Boolean = false, // Current session's active model label (provider/model), shown in the chip val currentSessionModel: String? = null, + // Per-model reasoning capabilities for the current session's model + // (issue #946). Null when unknown β€” UI offers full scale. + val currentModelCapabilities: ModelCapabilities? = null, // Reasoning effort level for the current session val reasoningLevel: String? = null, val terminalBackend: String? = null, @@ -810,6 +814,7 @@ class ChatViewModel( fullContextTokens = if (modelSwapped) null else state.fullContextTokens, ) } + syncCurrentModelCapabilities() if (modelSwapped) { // Issue #817: after a swap the REST model/info window is // PROFILE-scoped and may describe the old model (e.g. a @@ -1070,6 +1075,7 @@ class ChatViewModel( terminalBackend = terminalBackend ?: it.terminalBackend, ) } + syncCurrentModelCapabilities() // Mirror the active runtime session id app-wide (issue #532). ActiveSessionHolder.set(runtimeSessionId ?: sessionId, sessionId) addSystemMessage("Session resumed") @@ -1836,6 +1842,7 @@ class ChatViewModel( if (result is NetworkResult.Success) { cachedModelOptions = result.data.providers.orEmpty() _uiState.update { it.copy(modelPickerPinned = AuthManager.getPinnedModels()) } + syncCurrentModelCapabilities() } } } @@ -1878,6 +1885,7 @@ class ChatViewModel( modelPickerLoading = false, ) } + syncCurrentModelCapabilities() } is NetworkResult.Failure -> { @@ -1932,6 +1940,7 @@ class ChatViewModel( currentSessionModel = "$provider/$model", ) } + syncCurrentModelCapabilities() // Model switch changes the context-window denominator β€” refetch it. fetchContextUsage() handleSlashCommand("/model $model --provider $provider --session") @@ -1963,6 +1972,40 @@ class ChatViewModel( } } + fun getModelCapabilities( + providerSlug: String, + modelName: String, + ): ModelCapabilities? = cachedModelOptions.find { it.slug == providerSlug }?.capabilities?.get(modelName) + + fun getCurrentModelCapabilities(): ModelCapabilities? { + val label = _uiState.value.currentSessionModel ?: return null + val idx = label.indexOf('/') + if (idx <= 0) return null + val provider = label.substring(0, idx) + val model = label.substring(idx + 1) + return getModelCapabilities(provider, model) + } + + private fun syncCurrentModelCapabilities() { + val label = _uiState.value.currentSessionModel + val caps = + if (label == null) { + null + } else { + val idx = label.indexOf('/') + if (idx <= 0) { + null + } else { + val provider = label.substring(0, idx) + val model = label.substring(idx + 1) + cachedModelOptions.find { it.slug == provider }?.capabilities?.get(model) + } + } + _uiState.update { state -> + if (state.currentModelCapabilities != caps) state.copy(currentModelCapabilities = caps) else state + } + } + fun switchSession(sessionId: String) { if (sessionId == _uiState.value.currentSessionId) return @@ -2167,6 +2210,7 @@ class ChatViewModel( showModelPicker = false, modelPickerLoading = false, currentSessionModel = null, + currentModelCapabilities = null, reasoningLevel = null, terminalBackend = null, usedContextTokens = null, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt index 01745b4d..6dcaf178 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt @@ -89,6 +89,8 @@ fun ChatInputBar( reasoningLevel: String? = null, onModelTap: () -> Unit = {}, onReasoningTap: (String?) -> Unit = {}, + canDisableReasoning: Boolean? = null, + supportsReasoning: Boolean? = null, ) { // Allow sending while the agent is mid-turn or awaiting approval: the // gateway's prompt.submit busy-input policy queues it as the next turn @@ -319,6 +321,8 @@ fun ChatInputBar( onReasoningSelected = onReasoningTap, onMicTap = onMicTap, modifier = Modifier.testTag("chat_composer_toolbar"), + canDisableReasoning = canDisableReasoning, + supportsReasoning = supportsReasoning, ) // Attachment dropdown (anchored to the attach button in ComposerToolbar) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt index fdc316e1..8e161627 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt @@ -41,6 +41,11 @@ import com.m57.hermescontrol.R * Layout: [πŸ“Ž attach] [model chip] [←spacerβ†’] [🧠 reasoning] [πŸŽ™ mic] * * The reasoning chip opens a dropdown menu to pick a level (instead of cycling). + * When [canDisableReasoning] is false the "None" level is disabled with a + * "reasoning always on" hint (issue #946). Absent key (null) means no + * restriction is known β€” full scale offered. + * When [supportsReasoning] is false the model takes no reasoning parameter + * and the chip is disabled. */ @Composable fun ComposerToolbar( @@ -53,8 +58,12 @@ fun ComposerToolbar( onReasoningSelected: (String?) -> Unit, onMicTap: () -> Unit, modifier: Modifier = Modifier, + canDisableReasoning: Boolean? = null, + supportsReasoning: Boolean? = null, ) { var showReasoningMenu by remember { mutableStateOf(false) } + val reasoningDisabledForModel = supportsReasoning == false + val canDisable = canDisableReasoning Row( modifier = @@ -105,9 +114,15 @@ fun ComposerToolbar( FilterChip( selected = reasoningLevel != null, onClick = { showReasoningMenu = true }, + enabled = !reasoningDisabledForModel, label = { Text( - text = buildReasoningLabel(reasoningLevel), + text = + if (reasoningDisabledForModel) { + "No reasoning" + } else { + buildReasoningLabel(reasoningLevel) + }, style = MaterialTheme.typography.bodySmall, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -129,6 +144,22 @@ fun ComposerToolbar( color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), ) + if (canDisable == false) { + Text( + text = "reasoning always on", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + modifier = Modifier.padding(horizontal = 12.dp, vertical = 2.dp), + ) + } + if (reasoningDisabledForModel) { + Text( + text = "no reasoning parameter for this model", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + modifier = Modifier.padding(horizontal = 12.dp, vertical = 2.dp), + ) + } HorizontalDivider() val allLevels = listOf( @@ -142,6 +173,8 @@ fun ComposerToolbar( "ultra" to "Ultra", ) allLevels.forEach { (level, label) -> + val isNone = level == "none" + val noneDisabled = isNone && (canDisable == false || reasoningDisabledForModel) DropdownMenuItem( text = { Text( @@ -153,10 +186,10 @@ fun ComposerToolbar( null }, color = - if (reasoningLevel == level) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurface + when { + noneDisabled -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + reasoningLevel == level -> MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.onSurface }, ) }, @@ -164,6 +197,7 @@ fun ComposerToolbar( showReasoningMenu = false onReasoningSelected(level) }, + enabled = !noneDisabled && !reasoningDisabledForModel, ) } } 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 c429b731..a8baf000 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 @@ -782,6 +782,7 @@ private fun ProviderCard( val isActive = activeProfile?.provider == provider.slug && activeProfile.model == model + val caps = provider.capabilities?.get(model) Card( onClick = { onModelClick(provider.slug, model) }, modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), @@ -810,21 +811,37 @@ private fun ProviderCard( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - Text( - text = model, - style = MaterialTheme.typography.bodyMedium, - fontWeight = if (isActive) FontWeight.Bold else FontWeight.Normal, - color = - if (isActive) { - MaterialTheme.colorScheme.onPrimaryContainer - } else { - MaterialTheme.colorScheme.onSurface - }, - // Weight + wrap: a long model name grows - // the row vertically instead of pushing - // the check/pin buttons off-screen. + Column( modifier = Modifier.weight(1f), - ) + ) { + Text( + text = model, + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (isActive) FontWeight.Bold else FontWeight.Normal, + color = + if (isActive) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurface + }, + // Weight + wrap: a long model name grows + // 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, + ) + } + } Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), 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 fe0acb78..942f056a 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 @@ -50,6 +50,9 @@ import com.m57.hermescontrol.ui.common.SearchBar * [pinnedModels] renders a pinned section at the top (mirrors the global model * screen's pin section) so frequently-used models are one tap away. * [onPinToggle] allows pinning and unpinning models directly inside the dialog. + * Per-model reasoning capabilities (issue #946) are surfaced as a small hint + * when `can_disable_reasoning == false` ("reasoning always on") or + * `reasoning == false` ("no reasoning"). */ @Composable fun ModelPickerDialog( @@ -123,6 +126,11 @@ fun ModelPickerDialog( pinnedModels.map { "${it.providerSlug}:${it.modelName}" }.toSet() } + val providerBySlug = + remember(providers) { + providers.associateBy { it.slug } + } + val filteredProvidersWithModels = remember(pickerQuery, providers) { providers.mapNotNull { provider -> @@ -178,6 +186,7 @@ fun ModelPickerDialog( filteredPinned, key = { "pinned:${it.providerSlug}:${it.modelName}" }, ) { pinned -> + val caps = providerBySlug[pinned.providerSlug]?.capabilities?.get(pinned.modelName) ModelItemCard( modelName = pinned.modelName, isPinned = true, @@ -188,6 +197,8 @@ fun ModelPickerDialog( null }, onClick = { onSelect(pinned.providerSlug, pinned.modelName) }, + canDisableReasoning = caps?.can_disable_reasoning, + supportsReasoning = caps?.reasoning, ) } } @@ -219,6 +230,7 @@ fun ModelPickerDialog( key = { model -> "${provider.slug}:$model" }, ) { model -> val isPinned = "${provider.slug}:$model" in pinnedSet + val caps = provider.capabilities?.get(model) ModelItemCard( modelName = model, isPinned = isPinned, @@ -229,6 +241,8 @@ fun ModelPickerDialog( null }, onClick = { onSelect(provider.slug, model) }, + canDisableReasoning = caps?.can_disable_reasoning, + supportsReasoning = caps?.reasoning, ) } } @@ -247,6 +261,8 @@ private fun ModelItemCard( onPinToggle: (() -> Unit)?, onClick: () -> Unit, modifier: Modifier = Modifier, + canDisableReasoning: Boolean? = null, + supportsReasoning: Boolean? = null, ) { Surface( modifier = @@ -267,15 +283,31 @@ private fun ModelItemCard( modifier = Modifier.padding(start = 12.dp, end = 4.dp, top = 4.dp, bottom = 4.dp), verticalAlignment = Alignment.CenterVertically, ) { - Text( - text = modelName, - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onSurface, - // No maxLines/ellipsis: a long model name wraps so the full - // name stays readable while the pin button keeps its spot. + Column( modifier = Modifier.weight(1f), - ) + ) { + Text( + text = modelName, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface, + // 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, + ) + } + } if (onPinToggle != null) { IconButton( onClick = onPinToggle,