feat: correx-managed model lifecycle slice 2 — per-stage model selection
StageConfig.modelId; InferenceRouter gains a backward-compatible model-aware route() overload (default ignores modelId, so static routers and test fakes are unaffected). New ManagedInferenceRouter (infra commons) resolves a target model per stage (stage.modelId > capability match > default) and ensureLoaded()s it before routing, returning the live managed provider — its id changes with the resident model, so there is no stale health cache to invalidate on swap. ModelManager.ensureLoaded default delegates to the idempotent load(). Main wires the managed router on the [[models]] path; the static path keeps DefaultInferenceRouter. Plan: docs/plans/2026-05-31-model-lifecycle-management.md (slice 2 of 5).
This commit is contained in:
@@ -15,6 +15,7 @@ dependencies {
|
||||
testImplementation(project(":testing:fixtures"))
|
||||
testImplementation(project(":core:inference"))
|
||||
testImplementation(project(":core:context"))
|
||||
testImplementation(project(":infrastructure:inference:commons"))
|
||||
testFixturesImplementation(project(":core:events"))
|
||||
testFixturesImplementation(project(":core:sessions"))
|
||||
testFixturesImplementation(project(":core:artifacts-store"))
|
||||
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
package com.correx.testing.contracts.model.inference
|
||||
|
||||
import com.correx.core.events.types.ProviderId
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.inference.CapabilityScore
|
||||
import com.correx.core.inference.InferenceProvider
|
||||
import com.correx.core.inference.ModelCapability
|
||||
import com.correx.core.inference.NoEligibleProviderException
|
||||
import com.correx.core.inference.ProviderHealth
|
||||
import com.correx.infrastructure.inference.commons.ManagedInferenceRouter
|
||||
import com.correx.infrastructure.inference.commons.ModelDescriptor
|
||||
import com.correx.infrastructure.inference.commons.ModelManager
|
||||
import com.correx.infrastructure.inference.commons.ResidencyMode
|
||||
import com.correx.testing.fixtures.inference.MockInferenceProvider
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.assertThrows
|
||||
|
||||
class ManagedInferenceRouterTest {
|
||||
|
||||
private val stage = StageId("s1")
|
||||
|
||||
/** Records every (idempotent) load/ensureLoaded so we can assert which model was made resident. */
|
||||
private class FakeModelManager : ModelManager {
|
||||
val loads = mutableListOf<String>()
|
||||
private var current: ModelDescriptor? = null
|
||||
|
||||
override suspend fun load(descriptor: ModelDescriptor): InferenceProvider {
|
||||
loads.add(descriptor.modelId)
|
||||
current = descriptor
|
||||
return MockInferenceProvider(id = ProviderId("managed:${descriptor.modelId}"))
|
||||
}
|
||||
|
||||
override suspend fun unload(modelId: String) { current = null }
|
||||
override fun currentModel(): ModelDescriptor? = current
|
||||
override fun healthCheck(): ProviderHealth = ProviderHealth.Healthy
|
||||
}
|
||||
|
||||
private fun desc(id: String, vararg caps: Pair<ModelCapability, Double>): ModelDescriptor =
|
||||
ModelDescriptor(
|
||||
modelId = id,
|
||||
modelPath = "/models/$id",
|
||||
residencyMode = ResidencyMode.PERSISTENT,
|
||||
capabilities = caps.map { CapabilityScore(it.first, it.second) }.toSet(),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `stage modelId takes precedence over capability and default`(): Unit = runBlocking {
|
||||
val manager = FakeModelManager()
|
||||
val router = ManagedInferenceRouter(
|
||||
manager = manager,
|
||||
descriptors = listOf(desc("default-model"), desc("coder", ModelCapability.Coding to 1.0)),
|
||||
defaultModelId = "default-model",
|
||||
)
|
||||
|
||||
val provider = router.route(stage, setOf(ModelCapability.Coding), modelId = "coder")
|
||||
|
||||
assertEquals("managed:coder", provider.id.value)
|
||||
assertEquals(listOf("coder"), manager.loads)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `capability match selects a model that covers the required capabilities`(): Unit = runBlocking {
|
||||
val manager = FakeModelManager()
|
||||
val router = ManagedInferenceRouter(
|
||||
manager = manager,
|
||||
descriptors = listOf(
|
||||
desc("generalist", ModelCapability.General to 1.0),
|
||||
desc("coder", ModelCapability.Coding to 1.0),
|
||||
),
|
||||
defaultModelId = "generalist",
|
||||
)
|
||||
|
||||
val provider = router.route(stage, setOf(ModelCapability.Coding), modelId = null)
|
||||
|
||||
assertEquals("managed:coder", provider.id.value)
|
||||
assertEquals(listOf("coder"), manager.loads)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `capability match prefers the highest summed score`(): Unit = runBlocking {
|
||||
val manager = FakeModelManager()
|
||||
val router = ManagedInferenceRouter(
|
||||
manager = manager,
|
||||
descriptors = listOf(
|
||||
desc("weak", ModelCapability.Coding to 0.3),
|
||||
desc("strong", ModelCapability.Coding to 0.9),
|
||||
),
|
||||
defaultModelId = "weak",
|
||||
)
|
||||
|
||||
val provider = router.route(stage, setOf(ModelCapability.Coding), modelId = null)
|
||||
|
||||
assertEquals("managed:strong", provider.id.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `falls back to default when no modelId and no required capabilities`(): Unit = runBlocking {
|
||||
val manager = FakeModelManager()
|
||||
val router = ManagedInferenceRouter(
|
||||
manager = manager,
|
||||
descriptors = listOf(desc("default-model"), desc("coder", ModelCapability.Coding to 1.0)),
|
||||
defaultModelId = "default-model",
|
||||
)
|
||||
|
||||
val provider = router.route(stage, emptySet(), modelId = null)
|
||||
|
||||
assertEquals("managed:default-model", provider.id.value)
|
||||
assertEquals(listOf("default-model"), manager.loads)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `falls back to default when no model satisfies the required capabilities`(): Unit = runBlocking {
|
||||
val manager = FakeModelManager()
|
||||
val router = ManagedInferenceRouter(
|
||||
manager = manager,
|
||||
descriptors = listOf(desc("default-model")),
|
||||
defaultModelId = "default-model",
|
||||
)
|
||||
|
||||
val provider = router.route(stage, setOf(ModelCapability.Reasoning), modelId = null)
|
||||
|
||||
assertEquals("managed:default-model", provider.id.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown explicit modelId throws NoEligibleProviderException`() {
|
||||
val manager = FakeModelManager()
|
||||
val router = ManagedInferenceRouter(
|
||||
manager = manager,
|
||||
descriptors = listOf(desc("default-model")),
|
||||
defaultModelId = "default-model",
|
||||
)
|
||||
|
||||
assertThrows<NoEligibleProviderException> {
|
||||
runBlocking { router.route(stage, emptySet(), modelId = "ghost") }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user