feat(chat): surface per-model reasoning capabilities (#946) (#951)

Add ModelCapabilities (capabilities: Map<String, ModelCapabilities>?)
to ModelProvider matching backend /api/model/options shape
{model: {fast, reasoning, can_disable_reasoning}}. Nullable/defaulted
for backward compat with old backends (ignoreUnknownKeys+SnakeCase).

Chat composer: gate reasoning dropdown on can_disable_reasoning and
supportsReasoning — "None" disabled with "reasoning always on" hint
when can_disable==false, chip disabled with "No reasoning" when
reasoning==false. Missing keys offer full scale.

Sync currentModelCapabilities from cachedModelOptions on
SessionInfo, resume, preload/refresh, and optimistic /model swap;
clear on resetSessionState. Used by ComposerToolbar via ChatScreen.

Model picker: surface same hints in ModelPickerDialog (pinned+all)
and ModelScreen ProviderCard.

Verified: ktlintCheck OK, compileDebugKotlin OK (rerun-tasks).

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-08-21 21:24:01 +03:00
1 parent 90c07e0aee
commit e9f2c726e4
7 files changed
+168 -27

No files matched your search

@@ -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,
)
}
@@ -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,
@@ -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)
@@ -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,
)
}
}