@@ -27,4 +27,5 @@ data class ModelInfoResponse(
|
|||||||
val config_context_length: Long? = null,
|
val config_context_length: Long? = null,
|
||||||
/** Effective context window actually in use (max of auto/config + caps). */
|
/** Effective context window actually in use (max of auto/config + caps). */
|
||||||
val effective_context_length: Long? = null,
|
val effective_context_length: Long? = null,
|
||||||
|
val capabilities: ModelCapabilities? = null,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -26,8 +26,60 @@ data class ModelCapabilities(
|
|||||||
val fast: Boolean? = null,
|
val fast: Boolean? = null,
|
||||||
val reasoning: Boolean? = null,
|
val reasoning: Boolean? = null,
|
||||||
val can_disable_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
|
@Serializable
|
||||||
data class PinnedModel(
|
data class PinnedModel(
|
||||||
val providerSlug: String,
|
val providerSlug: String,
|
||||||
|
|||||||
@@ -300,7 +300,9 @@ interface HermesApiService : KanbanApiService {
|
|||||||
): Response<SessionInfo>
|
): Response<SessionInfo>
|
||||||
|
|
||||||
@GET("api/model/info")
|
@GET("api/model/info")
|
||||||
suspend fun getModelInfo(): Response<ModelInfoResponse>
|
suspend fun getModelInfo(
|
||||||
|
@Query("profile") profile: String? = null,
|
||||||
|
): Response<ModelInfoResponse>
|
||||||
|
|
||||||
@GET("api/system/stats")
|
@GET("api/system/stats")
|
||||||
suspend fun getSystemStats(): Response<SystemStatsResponse>
|
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.AuthManager
|
||||||
import com.m57.hermescontrol.data.local.DataScope
|
import com.m57.hermescontrol.data.local.DataScope
|
||||||
import com.m57.hermescontrol.data.model.ModelCapabilities
|
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.ModelProvider
|
||||||
import com.m57.hermescontrol.data.model.PinnedModel
|
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.ApiClient
|
||||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
import com.m57.hermescontrol.data.remote.safeApiCall
|
||||||
@@ -77,6 +80,9 @@ class ChatModelSwitchDelegate(
|
|||||||
private var cachedModelOptionsScope: DataScope? = dataScopeFlow.value
|
private var cachedModelOptionsScope: DataScope? = dataScopeFlow.value
|
||||||
private var modelOptionsJob: Job? = null
|
private var modelOptionsJob: Job? = null
|
||||||
private var modelOptionsJobScope: DataScope? = 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) {
|
fun attachScopeObserver(externalScope: CoroutineScope) {
|
||||||
externalScope.launch {
|
externalScope.launch {
|
||||||
@@ -89,6 +95,9 @@ class ChatModelSwitchDelegate(
|
|||||||
cachedModelOptions = emptyList()
|
cachedModelOptions = emptyList()
|
||||||
cachedModelOptionsScope = newScope
|
cachedModelOptionsScope = newScope
|
||||||
fastRejectedModels.clear()
|
fastRejectedModels.clear()
|
||||||
|
modelInfoCapabilities = null
|
||||||
|
modelInfoModel = null
|
||||||
|
modelInfoScope = null
|
||||||
|
|
||||||
if (modelOptionsJobScope != newScope) {
|
if (modelOptionsJobScope != newScope) {
|
||||||
modelOptionsJob?.cancel()
|
modelOptionsJob?.cancel()
|
||||||
@@ -469,6 +478,9 @@ class ChatModelSwitchDelegate(
|
|||||||
reasoningOpSequence++
|
reasoningOpSequence++
|
||||||
reasoningJob?.cancel()
|
reasoningJob?.cancel()
|
||||||
reasoningJob = null
|
reasoningJob = null
|
||||||
|
modelInfoCapabilities = null
|
||||||
|
modelInfoModel = null
|
||||||
|
modelInfoScope = null
|
||||||
uiState.update {
|
uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
pendingReasoningLevel = null,
|
pendingReasoningLevel = null,
|
||||||
@@ -492,7 +504,7 @@ class ChatModelSwitchDelegate(
|
|||||||
// Capability validation
|
// Capability validation
|
||||||
if (enforceCapabilities) {
|
if (enforceCapabilities) {
|
||||||
val caps = uiState.value.currentModelCapabilities
|
val caps = uiState.value.currentModelCapabilities
|
||||||
if (caps?.reasoning == false) return
|
if (caps?.reasoningSupport == false) return
|
||||||
if (level == "none" && caps?.can_disable_reasoning == false) return
|
if (level == "none" && caps?.can_disable_reasoning == false) return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -578,10 +590,26 @@ class ChatModelSwitchDelegate(
|
|||||||
modelName: String,
|
modelName: String,
|
||||||
): ModelCapabilities? {
|
): ModelCapabilities? {
|
||||||
if (cachedModelOptionsScope != dataScopeFlow.value) return null
|
if (cachedModelOptionsScope != dataScopeFlow.value) return null
|
||||||
return cachedModelOptions
|
val catalog =
|
||||||
.find { it.slug == providerSlug }
|
cachedModelOptions
|
||||||
?.capabilities
|
.find { it.slug == providerSlug }
|
||||||
?.get(modelName)
|
?.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? {
|
fun getCurrentModelCapabilities(): ModelCapabilities? {
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ import com.m57.hermescontrol.NavigationController
|
|||||||
import com.m57.hermescontrol.R
|
import com.m57.hermescontrol.R
|
||||||
import com.m57.hermescontrol.data.model.Attachment
|
import com.m57.hermescontrol.data.model.Attachment
|
||||||
import com.m57.hermescontrol.data.model.AttachmentSource
|
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.ConnectionStatus
|
||||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||||
import com.m57.hermescontrol.notification.NotificationHelper
|
import com.m57.hermescontrol.notification.NotificationHelper
|
||||||
@@ -840,7 +841,7 @@ fun ChatScreen(
|
|||||||
onModelTap = { viewModel.openModelPicker() },
|
onModelTap = { viewModel.openModelPicker() },
|
||||||
onReasoningTap = { level -> viewModel.setReasoningLevel(level) },
|
onReasoningTap = { level -> viewModel.setReasoningLevel(level) },
|
||||||
canDisableReasoning = state.currentModelCapabilities?.can_disable_reasoning,
|
canDisableReasoning = state.currentModelCapabilities?.can_disable_reasoning,
|
||||||
supportsReasoning = state.currentModelCapabilities?.reasoning,
|
supportsReasoning = state.currentModelCapabilities?.reasoningSupport,
|
||||||
fastMode = state.fastMode,
|
fastMode = state.fastMode,
|
||||||
fastSupported = state.currentModelCapabilities?.fast == true,
|
fastSupported = state.currentModelCapabilities?.fast == true,
|
||||||
isFastModeChanging = state.isFastModeChanging,
|
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.UsageSnapshotResponse
|
||||||
import com.m57.hermescontrol.data.model.parseContextBreakdown
|
import com.m57.hermescontrol.data.model.parseContextBreakdown
|
||||||
import com.m57.hermescontrol.data.model.parseUsageSnapshot
|
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.ApiClient
|
||||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||||
@@ -2344,7 +2345,7 @@ class ChatViewModel(
|
|||||||
if (value in REASONING_EFFORT_LEVELS) {
|
if (value in REASONING_EFFORT_LEVELS) {
|
||||||
if (parsed.scopeName != "global") {
|
if (parsed.scopeName != "global") {
|
||||||
val caps = _uiState.value.currentModelCapabilities
|
val caps = _uiState.value.currentModelCapabilities
|
||||||
if (caps?.reasoning == false) {
|
if (caps?.reasoningSupport == false) {
|
||||||
addAssistantMessage("Reasoning is not supported for the current model.")
|
addAssistantMessage("Reasoning is not supported for the current model.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -3988,6 +3989,7 @@ class ChatViewModel(
|
|||||||
// another model (e.g. Solar, Gemini), the profile-level model/info describes
|
// another model (e.g. Solar, Gemini), the profile-level model/info describes
|
||||||
// a different model and must never poison the session's context window.
|
// a different model and must never poison the session's context window.
|
||||||
if (isMatchingModel(currentModel, info)) {
|
if (isMatchingModel(currentModel, info)) {
|
||||||
|
modelSwitchDelegate.applyModelInfo(info, AuthManager.currentDataScope())
|
||||||
info.effective_context_length
|
info.effective_context_length
|
||||||
?: info.auto_context_length
|
?: info.auto_context_length
|
||||||
?: info.config_context_length
|
?: info.config_context_length
|
||||||
|
|||||||
+3
-2
@@ -32,6 +32,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import com.m57.hermescontrol.R
|
import com.m57.hermescontrol.R
|
||||||
import com.m57.hermescontrol.data.model.ModelProvider
|
import com.m57.hermescontrol.data.model.ModelProvider
|
||||||
import com.m57.hermescontrol.data.model.PinnedModel
|
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.KanbanModelOverride
|
||||||
import com.m57.hermescontrol.ui.kanban.supportedKanbanReasoningEfforts
|
import com.m57.hermescontrol.ui.kanban.supportedKanbanReasoningEfforts
|
||||||
import com.m57.hermescontrol.ui.model.components.ModelPickerDialog
|
import com.m57.hermescontrol.ui.model.components.ModelPickerDialog
|
||||||
@@ -129,7 +130,7 @@ fun KanbanModelOverrideEditor(
|
|||||||
.firstOrNull { it.slug == override.provider }
|
.firstOrNull { it.slug == override.provider }
|
||||||
?.capabilities
|
?.capabilities
|
||||||
?.get(override.model)
|
?.get(override.model)
|
||||||
val reasoningSupported = selectedCapabilities?.reasoning != false
|
val reasoningSupported = selectedCapabilities?.reasoningSupport != false
|
||||||
val canDisableReasoning = selectedCapabilities?.can_disable_reasoning != false
|
val canDisableReasoning = selectedCapabilities?.can_disable_reasoning != false
|
||||||
val supportedEfforts = supportedKanbanReasoningEfforts(reasoningSupported, canDisableReasoning)
|
val supportedEfforts = supportedKanbanReasoningEfforts(reasoningSupported, canDisableReasoning)
|
||||||
Row(
|
Row(
|
||||||
@@ -180,7 +181,7 @@ fun KanbanModelOverrideEditor(
|
|||||||
override.copy(
|
override.copy(
|
||||||
provider = provider,
|
provider = provider,
|
||||||
model = model,
|
model = model,
|
||||||
effort = if (capabilities?.reasoning == false) "" else override.effort,
|
effort = if (capabilities?.reasoningSupport == false) "" else override.effort,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
showPickerDialog = false
|
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.SkeletonListState
|
||||||
import com.m57.hermescontrol.ui.common.ToastEffect
|
import com.m57.hermescontrol.ui.common.ToastEffect
|
||||||
import com.m57.hermescontrol.ui.model.components.MoaConfigDialog
|
import com.m57.hermescontrol.ui.model.components.MoaConfigDialog
|
||||||
|
import com.m57.hermescontrol.ui.model.components.ModelCapabilitySummary
|
||||||
import com.m57.hermescontrol.ui.model.components.ModelPickerDialog
|
import com.m57.hermescontrol.ui.model.components.ModelPickerDialog
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@@ -874,19 +875,7 @@ private fun ProviderCard(
|
|||||||
// the row vertically instead of pushing
|
// the row vertically instead of pushing
|
||||||
// the check/pin buttons off-screen.
|
// the check/pin buttons off-screen.
|
||||||
)
|
)
|
||||||
val hint =
|
ModelCapabilitySummary(caps)
|
||||||
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(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
|||||||
@@ -3,20 +3,25 @@ package com.m57.hermescontrol.ui.model
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.m57.hermescontrol.data.local.AuthManager
|
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.AuxiliaryTaskAssignment
|
||||||
import com.m57.hermescontrol.data.model.MoaConfigResponse
|
import com.m57.hermescontrol.data.model.MoaConfigResponse
|
||||||
import com.m57.hermescontrol.data.model.ModelAssignmentRequest
|
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.ModelProvider
|
||||||
import com.m57.hermescontrol.data.model.PinnedModel
|
import com.m57.hermescontrol.data.model.PinnedModel
|
||||||
import com.m57.hermescontrol.data.model.ProfileInfo
|
import com.m57.hermescontrol.data.model.ProfileInfo
|
||||||
import com.m57.hermescontrol.data.model.UpdateProfileModelRequest
|
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.ApiClient
|
||||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
import com.m57.hermescontrol.data.remote.safeApiCall
|
||||||
import com.m57.hermescontrol.data.ws.ModelCatalogStore
|
import com.m57.hermescontrol.data.ws.ModelCatalogStore
|
||||||
import com.m57.hermescontrol.data.ws.ModelOptionsRepository
|
import com.m57.hermescontrol.data.ws.ModelOptionsRepository
|
||||||
import com.m57.hermescontrol.ui.common.ToastHost
|
import com.m57.hermescontrol.ui.common.ToastHost
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -45,6 +50,7 @@ data class ModelUiState(
|
|||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
val catalogLoading: Boolean = false,
|
val catalogLoading: Boolean = false,
|
||||||
val providers: List<ModelProvider> = emptyList(),
|
val providers: List<ModelProvider> = emptyList(),
|
||||||
|
val modelInfo: ModelInfoResponse? = null,
|
||||||
val activeProfile: ProfileInfo? = null,
|
val activeProfile: ProfileInfo? = null,
|
||||||
val errorMessage: String? = null,
|
val errorMessage: String? = null,
|
||||||
val toastMessage: String? = null,
|
val toastMessage: String? = null,
|
||||||
@@ -71,12 +77,19 @@ data class ModelUiState(
|
|||||||
|
|
||||||
class ModelViewModel(
|
class ModelViewModel(
|
||||||
private val catalogStore: ModelCatalogStore = ModelCatalogStore.shared,
|
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(),
|
) : ViewModel(),
|
||||||
ToastHost {
|
ToastHost {
|
||||||
constructor(repository: ModelOptionsRepository) : this(ModelCatalogStore(repository = repository))
|
constructor(repository: ModelOptionsRepository) : this(ModelCatalogStore(repository = repository))
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(ModelUiState())
|
private val _uiState = MutableStateFlow(ModelUiState())
|
||||||
val uiState: StateFlow<ModelUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<ModelUiState> = _uiState.asStateFlow()
|
||||||
|
private var modelInfoJob: Job? = null
|
||||||
|
private var modelInfoScope: DataScope? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
_uiState.update { it.copy(pinnedModels = AuthManager.getPinnedModels()) }
|
_uiState.update { it.copy(pinnedModels = AuthManager.getPinnedModels()) }
|
||||||
@@ -84,9 +97,18 @@ class ModelViewModel(
|
|||||||
catalogStore.state.collect { catalogState ->
|
catalogStore.state.collect { catalogState ->
|
||||||
// Empty state is meaningful: it is how ModelCatalogStore
|
// Empty state is meaningful: it is how ModelCatalogStore
|
||||||
// invalidates the previous DataScope. Never ignore it.
|
// 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 {
|
_uiState.update {
|
||||||
|
val modelInfo = if (scopeChanged) null else it.modelInfo
|
||||||
it.copy(
|
it.copy(
|
||||||
providers = catalogState.providers,
|
providers = catalogState.providers.withModelInfoCapabilities(modelInfo),
|
||||||
|
modelInfo = modelInfo,
|
||||||
catalogLoading = catalogState.isRefreshing,
|
catalogLoading = catalogState.isRefreshing,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -96,6 +118,7 @@ class ModelViewModel(
|
|||||||
|
|
||||||
fun loadAll(refresh: Boolean = false) {
|
fun loadAll(refresh: Boolean = false) {
|
||||||
_uiState.update { it.copy(isLoading = true, catalogLoading = true, errorMessage = null) }
|
_uiState.update { it.copy(isLoading = true, catalogLoading = true, errorMessage = null) }
|
||||||
|
loadModelCapabilities()
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
// Phase 1: Launch fast lightweight calls first (profiles, aux, moa)
|
// Phase 1: Launch fast lightweight calls first (profiles, aux, moa)
|
||||||
val activeProfileDeferred =
|
val activeProfileDeferred =
|
||||||
@@ -199,6 +222,7 @@ class ModelViewModel(
|
|||||||
|
|
||||||
/** Fast refresh after model assignments/updates without re-fetching full provider catalog. */
|
/** Fast refresh after model assignments/updates without re-fetching full provider catalog. */
|
||||||
fun loadQuick() {
|
fun loadQuick() {
|
||||||
|
loadModelCapabilities(clearExisting = true)
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val activeProfileDeferred =
|
val activeProfileDeferred =
|
||||||
async(Dispatchers.IO) {
|
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. */
|
/** Open the main model picker dialog. */
|
||||||
fun openMainModelPicker() {
|
fun openMainModelPicker() {
|
||||||
_uiState.update { it.copy(showMainModelPicker = true) }
|
_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.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.m57.hermescontrol.R
|
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.ModelProvider
|
||||||
import com.m57.hermescontrol.data.model.PinnedModel
|
import com.m57.hermescontrol.data.model.PinnedModel
|
||||||
import com.m57.hermescontrol.ui.common.LoadingState
|
import com.m57.hermescontrol.ui.common.LoadingState
|
||||||
@@ -213,8 +214,7 @@ fun ModelPickerDialog(
|
|||||||
null
|
null
|
||||||
},
|
},
|
||||||
onClick = { onSelect(pinned.providerSlug, pinned.modelName) },
|
onClick = { onSelect(pinned.providerSlug, pinned.modelName) },
|
||||||
canDisableReasoning = caps?.can_disable_reasoning,
|
capabilities = caps,
|
||||||
supportsReasoning = caps?.reasoning,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -294,8 +294,7 @@ fun ModelPickerDialog(
|
|||||||
null
|
null
|
||||||
},
|
},
|
||||||
onClick = { onSelect(provider.slug, model) },
|
onClick = { onSelect(provider.slug, model) },
|
||||||
canDisableReasoning = caps?.can_disable_reasoning,
|
capabilities = caps,
|
||||||
supportsReasoning = caps?.reasoning,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -315,8 +314,7 @@ private fun ModelItemCard(
|
|||||||
onPinToggle: (() -> Unit)?,
|
onPinToggle: (() -> Unit)?,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
canDisableReasoning: Boolean? = null,
|
capabilities: ModelCapabilities? = null,
|
||||||
supportsReasoning: Boolean? = null,
|
|
||||||
) {
|
) {
|
||||||
Surface(
|
Surface(
|
||||||
modifier =
|
modifier =
|
||||||
@@ -348,19 +346,7 @@ private fun ModelItemCard(
|
|||||||
// No maxLines/ellipsis: a long model name wraps so the full
|
// No maxLines/ellipsis: a long model name wraps so the full
|
||||||
// name stays readable while the pin button keeps its spot.
|
// name stays readable while the pin button keeps its spot.
|
||||||
)
|
)
|
||||||
val hint =
|
ModelCapabilitySummary(capabilities)
|
||||||
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) {
|
if (onPinToggle != null) {
|
||||||
IconButton(
|
IconButton(
|
||||||
|
|||||||
+130
@@ -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<ModelInfoResponse>(
|
||||||
|
"""
|
||||||
|
{"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<ModelInfoResponse>("""{"capabilities":{"supports_vision":false}}""")
|
||||||
|
val missing = json.decodeFromString<ModelInfoResponse>("{}")
|
||||||
|
val explicitNull = json.decodeFromString<ModelInfoResponse>("""{"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<ModelOptionsResponse>(
|
||||||
|
"""
|
||||||
|
{"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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package com.m57.hermescontrol.ui.chat
|
|||||||
|
|
||||||
import com.m57.hermescontrol.data.local.DataScope
|
import com.m57.hermescontrol.data.local.DataScope
|
||||||
import com.m57.hermescontrol.data.model.ModelCapabilities
|
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.ModelOptionsResponse
|
||||||
import com.m57.hermescontrol.data.model.ModelProvider
|
import com.m57.hermescontrol.data.model.ModelProvider
|
||||||
import com.m57.hermescontrol.data.model.PinnedModel
|
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.CompletableDeferred
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
import kotlinx.coroutines.test.TestScope
|
import kotlinx.coroutines.test.TestScope
|
||||||
import kotlinx.coroutines.test.advanceUntilIdle
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
@@ -175,6 +177,41 @@ class ChatModelSwitchDelegateTest {
|
|||||||
scopeObserverJob.cancel()
|
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
|
@Test
|
||||||
fun closeAndReopenDuringPreload_doesNotDuplicateOrReopenAfterDismiss() =
|
fun closeAndReopenDuringPreload_doesNotDuplicateOrReopenAfterDismiss() =
|
||||||
testScope.runTest {
|
testScope.runTest {
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package com.m57.hermescontrol.ui.model
|
|||||||
|
|
||||||
import com.m57.hermescontrol.data.local.AuthManager
|
import com.m57.hermescontrol.data.local.AuthManager
|
||||||
import com.m57.hermescontrol.data.local.DataScope
|
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.ModelOptionsResponse
|
||||||
import com.m57.hermescontrol.data.model.ModelProvider
|
import com.m57.hermescontrol.data.model.ModelProvider
|
||||||
import com.m57.hermescontrol.data.model.PinnedModel
|
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<String?>()
|
||||||
|
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
|
@Test
|
||||||
fun `pinModel shows toast when max cap reached`() {
|
fun `pinModel shows toast when max cap reached`() {
|
||||||
val viewModel = createViewModel()
|
val viewModel = createViewModel()
|
||||||
|
|||||||
Reference in New Issue
Block a user