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:
@@ -28,6 +28,8 @@ dependencies {
|
||||
implementation "io.ktor:ktor-client-content-negotiation:$ktor_version"
|
||||
implementation "io.ktor:ktor-serialization-kotlinx-json:$ktor_version"
|
||||
implementation "org.slf4j:slf4j-api:2.0.16"
|
||||
testImplementation "io.ktor:ktor-client-mock:$ktor_version"
|
||||
testImplementation "org.jetbrains.kotlin:kotlin-test"
|
||||
}
|
||||
|
||||
tasks.named("koverVerify").configure { enabled = false }
|
||||
|
||||
@@ -23,6 +23,8 @@ dependencies {
|
||||
implementation "io.ktor:ktor-serialization-kotlinx-json:$ktor_version"
|
||||
testImplementation "org.junit.jupiter:junit-jupiter"
|
||||
testImplementation "io.ktor:ktor-client-mock:$ktor_version"
|
||||
testImplementation project(':infrastructure:persistence')
|
||||
testImplementation "org.jetbrains.kotlin:kotlin-test"
|
||||
}
|
||||
|
||||
tasks.named("koverVerify").configure { enabled = false }
|
||||
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
package com.correx.infrastructure.inference.llama.cpp
|
||||
|
||||
import com.correx.core.events.EventDispatcher
|
||||
import com.correx.core.events.events.ModelLoadedEvent
|
||||
import com.correx.core.events.events.ModelUnloadedEvent
|
||||
import com.correx.infrastructure.inference.commons.ModelDescriptor
|
||||
import com.correx.infrastructure.inference.commons.ModelLoadException
|
||||
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 kotlinx.coroutines.runBlocking
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.assertThrows
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
|
||||
/**
|
||||
* Tests for [DefaultModelManager] lifecycle using a stub process and a mock HTTP health endpoint.
|
||||
*
|
||||
* Stub process: uses the system "sleep" binary called with llama-server-style args.
|
||||
* The process may exit quickly (args are not understood by sleep), but the mock HTTP
|
||||
* client returns "healthy" before the manager ever checks process liveness, so load()
|
||||
* succeeds. This approach intentionally avoids any dependency on a real llama-server
|
||||
* binary.
|
||||
*/
|
||||
class DefaultModelManagerTest {
|
||||
|
||||
private fun healthyHttpClient(): HttpClient {
|
||||
val engine = MockEngine {
|
||||
respond(
|
||||
content = """{"status":"healthy"}""",
|
||||
headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()),
|
||||
)
|
||||
}
|
||||
return HttpClient(engine)
|
||||
}
|
||||
|
||||
private fun unhealthyHttpClient(): HttpClient {
|
||||
val engine = MockEngine {
|
||||
respond(
|
||||
content = """{"status":"loading"}""",
|
||||
headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()),
|
||||
)
|
||||
}
|
||||
return HttpClient(engine)
|
||||
}
|
||||
|
||||
private fun descriptor(id: String = "test-model") = ModelDescriptor(
|
||||
modelId = id,
|
||||
modelPath = "/dev/null",
|
||||
residencyMode = ResidencyMode.PERSISTENT,
|
||||
contextSize = 512,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `load spawns process, polls health, emits ModelLoadedEvent, returns provider`(): Unit = runBlocking {
|
||||
val eventStore = InMemoryEventStore()
|
||||
val dispatcher = EventDispatcher(eventStore)
|
||||
val manager = DefaultModelManager(
|
||||
llamaServerBin = "sleep",
|
||||
host = "127.0.0.1",
|
||||
port = 29999,
|
||||
healthTimeoutMs = 5_000L,
|
||||
eventStore = eventStore,
|
||||
httpClient = healthyHttpClient(),
|
||||
eventDispatcher = dispatcher,
|
||||
)
|
||||
|
||||
val provider = manager.load(descriptor())
|
||||
|
||||
assertNotNull(provider)
|
||||
assertEquals("test-model", manager.currentModel()?.modelId)
|
||||
|
||||
val events = eventStore.allEvents().toList()
|
||||
val loadedEvent = events.firstOrNull { it.payload is ModelLoadedEvent }
|
||||
assertNotNull(loadedEvent, "ModelLoadedEvent must have been emitted")
|
||||
assertEquals("test-model", (loadedEvent!!.payload as ModelLoadedEvent).modelId)
|
||||
assertEquals("correx:model-manager", loadedEvent.metadata.sessionId.value)
|
||||
|
||||
manager.unload("test-model")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `currentModel returns null before any load`() {
|
||||
val eventStore = InMemoryEventStore()
|
||||
val dispatcher = EventDispatcher(eventStore)
|
||||
val manager = DefaultModelManager(
|
||||
llamaServerBin = "sleep",
|
||||
host = "127.0.0.1",
|
||||
port = 29998,
|
||||
healthTimeoutMs = 5_000L,
|
||||
eventStore = eventStore,
|
||||
httpClient = healthyHttpClient(),
|
||||
eventDispatcher = dispatcher,
|
||||
)
|
||||
|
||||
assertNull(manager.currentModel())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loading same model twice returns provider without re-spawning`(): Unit = runBlocking {
|
||||
val eventStore = InMemoryEventStore()
|
||||
val dispatcher = EventDispatcher(eventStore)
|
||||
val manager = DefaultModelManager(
|
||||
llamaServerBin = "sleep",
|
||||
host = "127.0.0.1",
|
||||
port = 29997,
|
||||
healthTimeoutMs = 5_000L,
|
||||
eventStore = eventStore,
|
||||
httpClient = healthyHttpClient(),
|
||||
eventDispatcher = dispatcher,
|
||||
)
|
||||
|
||||
manager.load(descriptor("model-a"))
|
||||
manager.load(descriptor("model-a"))
|
||||
|
||||
// Only one ModelLoadedEvent because the second load is a no-op
|
||||
val loadedEvents = eventStore.allEvents().filter { it.payload is ModelLoadedEvent }.toList()
|
||||
assertEquals(1, loadedEvents.size)
|
||||
|
||||
manager.unload("model-a")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unload emits ModelUnloadedEvent and clears currentModel`(): Unit = runBlocking {
|
||||
val eventStore = InMemoryEventStore()
|
||||
val dispatcher = EventDispatcher(eventStore)
|
||||
val manager = DefaultModelManager(
|
||||
llamaServerBin = "sleep",
|
||||
host = "127.0.0.1",
|
||||
port = 29996,
|
||||
healthTimeoutMs = 5_000L,
|
||||
eventStore = eventStore,
|
||||
httpClient = healthyHttpClient(),
|
||||
eventDispatcher = dispatcher,
|
||||
)
|
||||
|
||||
manager.load(descriptor("model-b"))
|
||||
manager.unload("model-b")
|
||||
|
||||
assertNull(manager.currentModel())
|
||||
|
||||
val unloadedEvents = eventStore.allEvents().filter { it.payload is ModelUnloadedEvent }.toList()
|
||||
assertEquals(1, unloadedEvents.size)
|
||||
assertEquals("model-b", (unloadedEvents[0].payload as ModelUnloadedEvent).modelId)
|
||||
assertEquals("correx:model-manager", unloadedEvents[0].metadata.sessionId.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unload without prior load throws ModelLoadException`(): Unit = runBlocking {
|
||||
val eventStore = InMemoryEventStore()
|
||||
val dispatcher = EventDispatcher(eventStore)
|
||||
val manager = DefaultModelManager(
|
||||
llamaServerBin = "sleep",
|
||||
host = "127.0.0.1",
|
||||
port = 29995,
|
||||
healthTimeoutMs = 5_000L,
|
||||
eventStore = eventStore,
|
||||
httpClient = healthyHttpClient(),
|
||||
eventDispatcher = dispatcher,
|
||||
)
|
||||
|
||||
assertThrows<ModelLoadException> { runBlocking { manager.unload("nonexistent") } }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `load throws ModelLoadException on health timeout`(): Unit = runBlocking {
|
||||
val eventStore = InMemoryEventStore()
|
||||
val dispatcher = EventDispatcher(eventStore)
|
||||
val manager = DefaultModelManager(
|
||||
llamaServerBin = "sleep",
|
||||
host = "127.0.0.1",
|
||||
port = 29994,
|
||||
healthTimeoutMs = 100L,
|
||||
eventStore = eventStore,
|
||||
httpClient = unhealthyHttpClient(),
|
||||
eventDispatcher = dispatcher,
|
||||
)
|
||||
|
||||
assertThrows<ModelLoadException> { runBlocking { manager.load(descriptor("timeout-model")) } }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
+88
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user