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

@@ -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 {
@@ -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<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
fun `pinModel shows toast when max cap reached`() {
val viewModel = createViewModel()