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:
+70
@@ -0,0 +1,70 @@
|
||||
package com.correx.infrastructure.inference.commons
|
||||
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.inference.InferenceProvider
|
||||
import com.correx.core.inference.InferenceRouter
|
||||
import com.correx.core.inference.ModelCapability
|
||||
import com.correx.core.inference.NoEligibleProviderException
|
||||
|
||||
/**
|
||||
* An [InferenceRouter] that owns the local model lifecycle: it resolves a single target model
|
||||
* per stage and ensures it is resident (via [ModelManager.ensureLoaded]) before returning the
|
||||
* managed provider to run inference against.
|
||||
*
|
||||
* Resolution precedence (per the model-lifecycle spec):
|
||||
* ```
|
||||
* stage.modelId > capability match over configured models > defaultModelId
|
||||
* ```
|
||||
* The manual-swap pin (a higher-precedence override) layers in on top of this in a later slice.
|
||||
*
|
||||
* Because the returned provider is the live managed provider (its [InferenceProvider.id] changes
|
||||
* with the resident model), there is no stale router-level health cache to invalidate on swap —
|
||||
* selection always reflects the model the manager just made resident.
|
||||
*/
|
||||
class ManagedInferenceRouter(
|
||||
private val manager: ModelManager,
|
||||
descriptors: List<ModelDescriptor>,
|
||||
private val defaultModelId: String,
|
||||
) : InferenceRouter {
|
||||
|
||||
private val byId: Map<String, ModelDescriptor> = descriptors.associateBy { it.modelId }
|
||||
private val ordered: List<ModelDescriptor> = descriptors
|
||||
|
||||
override suspend fun route(
|
||||
stageId: StageId,
|
||||
requiredCapabilities: Set<ModelCapability>,
|
||||
): InferenceProvider = route(stageId, requiredCapabilities, null)
|
||||
|
||||
override suspend fun route(
|
||||
stageId: StageId,
|
||||
requiredCapabilities: Set<ModelCapability>,
|
||||
modelId: String?,
|
||||
): InferenceProvider {
|
||||
val targetId = modelId
|
||||
?: capabilityMatch(requiredCapabilities)
|
||||
?: defaultModelId
|
||||
val descriptor = byId[targetId]
|
||||
?: throw NoEligibleProviderException(stageId, requiredCapabilities)
|
||||
return manager.ensureLoaded(descriptor)
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects the configured model whose declared capabilities cover every required capability,
|
||||
* preferring the highest summed score over those required capabilities. Returns null when no
|
||||
* capabilities are required or none of the models satisfy them (callers fall back to default).
|
||||
*/
|
||||
private fun capabilityMatch(required: Set<ModelCapability>): String? {
|
||||
if (required.isEmpty()) return null
|
||||
return ordered
|
||||
.filter { descriptor ->
|
||||
val declared = descriptor.capabilities.map { it.capability }.toSet()
|
||||
declared.containsAll(required)
|
||||
}
|
||||
.maxByOrNull { descriptor ->
|
||||
descriptor.capabilities
|
||||
.filter { it.capability in required }
|
||||
.sumOf { it.score }
|
||||
}
|
||||
?.modelId
|
||||
}
|
||||
}
|
||||
+7
@@ -16,6 +16,13 @@ interface ModelManager {
|
||||
*/
|
||||
suspend fun load(descriptor: ModelDescriptor): InferenceProvider
|
||||
|
||||
/**
|
||||
* Ensures the model described by [descriptor] is the resident model, returning its provider.
|
||||
* A no-op load if that model is already current; otherwise loads it (evicting the previous
|
||||
* model). [load] is idempotent for the already-loaded model, so the default delegates to it.
|
||||
*/
|
||||
suspend fun ensureLoaded(descriptor: ModelDescriptor): InferenceProvider = load(descriptor)
|
||||
|
||||
/**
|
||||
* Unloads the currently loaded model.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user