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:
2026-06-01 11:35:51 +04:00
parent e45a626cc4
commit 382194b498
8 changed files with 240 additions and 2 deletions
+1
View File
@@ -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"))
@@ -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") }
}
}
}