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

@@ -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.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()