feat: correx-managed model lifecycle slice 1 — config + manager factory + boot/shutdown

[[models]] config (ModelConfig/ModelsSettings) parsed by ConfigLoader;
InfrastructureModule.createModelManager + modelConfigToDescriptor; Main.kt
managed boot path spawns the default llama-server when [[models]] is present
(static [[providers]] path preserved when absent) and kills it on shutdown.

Plan: docs/plans/2026-05-31-model-lifecycle-management.md (slice 1 of 5).
This commit is contained in:
2026-06-01 11:03:05 +04:00
parent 5beb866036
commit e45a626cc4
10 changed files with 576 additions and 39 deletions
@@ -8,6 +8,8 @@ import com.correx.core.artifacts.repository.ArtifactRepository
import com.correx.core.artifactstore.ArtifactStore
import com.correx.core.config.EmbedderConfig
import com.correx.core.config.L3Config
import com.correx.core.config.ModelConfig
import com.correx.core.config.ModelsSettings
import com.correx.core.events.EventDispatcher
import com.correx.core.events.stores.EventStore
import com.correx.core.inference.CapabilityScore
@@ -35,8 +37,11 @@ import com.correx.infrastructure.artifactscas.index.SqliteArtifactIndex
import com.correx.infrastructure.inference.DefaultProviderRegistry
import com.correx.infrastructure.inference.commons.ModelDescriptor
import com.correx.infrastructure.inference.commons.ResidencyMode
import com.correx.infrastructure.inference.llama.cpp.DefaultModelManager
import com.correx.infrastructure.inference.llama.cpp.LlamaCppEmbedder
import com.correx.infrastructure.inference.llama.cpp.LlamaCppInferenceProvider
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import com.correx.infrastructure.router.turbovec.TurboVecL3MemoryStore
import com.correx.infrastructure.router.turbovec.TurboVecSidecarConfig
import com.correx.infrastructure.persistence.SqliteEventStore
@@ -110,6 +115,49 @@ object InfrastructureModule {
baseUrl = baseUrl,
)
fun createModelManager(
settings: ModelsSettings,
eventStore: EventStore,
eventDispatcher: EventDispatcher,
httpClient: HttpClient = HttpClient(CIO),
healthTimeoutMs: Long = 30_000L,
): DefaultModelManager = DefaultModelManager(
llamaServerBin = settings.llamaServerBin,
host = settings.host,
port = settings.port,
healthTimeoutMs = healthTimeoutMs,
eventStore = eventStore,
httpClient = httpClient,
eventDispatcher = eventDispatcher,
)
fun modelConfigToDescriptor(config: ModelConfig): ModelDescriptor = ModelDescriptor(
modelId = config.id,
modelPath = config.modelPath,
residencyMode = ResidencyMode.PERSISTENT,
contextSize = config.contextSize,
capabilities = parseCapabilitiesFromConfig(config.capabilities),
)
private fun parseCapabilitiesFromConfig(capsMap: Map<String, Double>): Set<CapabilityScore> {
val result = mutableSetOf<CapabilityScore>()
capsMap.forEach { (capName, score) ->
val capability = when (capName.lowercase()) {
"general" -> ModelCapability.General
"coding" -> ModelCapability.Coding
"reasoning" -> ModelCapability.Reasoning
"summarization" -> ModelCapability.Summarization
"toolcalling" -> ModelCapability.ToolCalling
else -> {
log.warn("Unknown capability name '{}' in [[models]] config", capName)
return@forEach
}
}
result.add(CapabilityScore(capability, score))
}
return result
}
fun createProviderRegistry(
providers: List<InferenceProvider> = emptyList(),
): DefaultProviderRegistry = DefaultProviderRegistry(providers)
@@ -0,0 +1,88 @@
package com.correx.infrastructure
import com.correx.core.config.ModelConfig
import com.correx.core.config.ModelsSettings
import com.correx.core.events.EventDispatcher
import com.correx.core.inference.ModelCapability
import com.correx.infrastructure.inference.commons.ResidencyMode
import com.correx.infrastructure.persistence.InMemoryEventStore
import io.ktor.client.HttpClient
import io.ktor.client.engine.mock.MockEngine
import io.ktor.client.engine.mock.respond
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.headersOf
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
class InfrastructureModuleModelTest {
@Test
fun `modelConfigToDescriptor maps fields correctly`() {
val config = ModelConfig(
id = "my-model",
modelPath = "/models/my-model.gguf",
contextSize = 4096,
capabilities = mapOf("General" to 1.0, "Coding" to 0.8),
)
val descriptor = InfrastructureModule.modelConfigToDescriptor(config)
assertEquals("my-model", descriptor.modelId)
assertEquals("/models/my-model.gguf", descriptor.modelPath)
assertEquals(4096, descriptor.contextSize)
assertEquals(ResidencyMode.PERSISTENT, descriptor.residencyMode)
assertEquals(2, descriptor.capabilities.size)
val generalScore = descriptor.capabilities.firstOrNull { it.capability == ModelCapability.General }
assertNotNull(generalScore)
assertEquals(1.0, generalScore!!.score)
val codingScore = descriptor.capabilities.firstOrNull { it.capability == ModelCapability.Coding }
assertNotNull(codingScore)
assertEquals(0.8, codingScore!!.score)
}
@Test
fun `modelConfigToDescriptor handles empty capabilities`() {
val config = ModelConfig(
id = "bare-model",
modelPath = "/models/bare.gguf",
)
val descriptor = InfrastructureModule.modelConfigToDescriptor(config)
assertEquals("bare-model", descriptor.modelId)
assertEquals(8192, descriptor.contextSize)
assertEquals(0, descriptor.capabilities.size)
}
@Test
fun `createModelManager returns DefaultModelManager with correct settings`() {
val settings = ModelsSettings(
defaultModel = "test-model",
llamaServerBin = "/custom/llama-server",
host = "10.0.0.1",
port = 12345,
)
val eventStore = InMemoryEventStore()
val dispatcher = EventDispatcher(eventStore)
val mockEngine = MockEngine {
respond(
content = """{"status":"healthy"}""",
headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()),
)
}
val httpClient = HttpClient(mockEngine)
val manager = InfrastructureModule.createModelManager(
settings = settings,
eventStore = eventStore,
eventDispatcher = dispatcher,
httpClient = httpClient,
)
assertNotNull(manager)
// currentModel is null before any load
assertEquals(null, manager.currentModel())
}
}