14 files changed
+451
-43
No files matched your search
+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.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()
|
||||
|
||||
Reference in new issue
Block a user