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
@@ -35,7 +35,9 @@ object ConfigLoader {
var currentSection = ""
val sections = mutableMapOf<String, MutableMap<String, Any>>()
val providers = mutableListOf<MutableMap<String, Any>>()
val models = mutableListOf<MutableMap<String, Any>>()
var currentProvider: MutableMap<String, Any>? = null
var currentModel: MutableMap<String, Any>? = null
for ((lineNum, line) in lines.withIndex()) {
val trimmed = line.trim()
@@ -46,18 +48,28 @@ object ConfigLoader {
}
trimmed == "[[providers]]" -> {
// Start a new provider entry
if (currentProvider != null) {
providers.add(currentProvider)
}
if (currentProvider != null) providers.add(currentProvider)
if (currentModel != null) { models.add(currentModel); currentModel = null }
currentProvider = mutableMapOf()
currentSection = ""
}
trimmed == "[[models]]" -> {
// Start a new model entry
if (currentModel != null) models.add(currentModel)
if (currentProvider != null) { providers.add(currentProvider); currentProvider = null }
currentModel = mutableMapOf()
currentSection = ""
}
trimmed.startsWith("[") && !trimmed.startsWith("[[") && trimmed.endsWith("]") -> {
// Parse section headers like [server] or [tools.shell]
if (currentProvider != null) {
providers.add(currentProvider)
currentProvider = null
}
if (currentModel != null) {
models.add(currentModel)
currentModel = null
}
currentSection = trimmed.substring(1, trimmed.length - 1).trim()
sections.putIfAbsent(currentSection, mutableMapOf())
}
@@ -69,22 +81,21 @@ object ConfigLoader {
val valueStr = trimmed.substring(eqIndex + 1).trim()
val parsedValue = parseValue(valueStr, lineNum + 1)
if (currentProvider != null) {
currentProvider[key] = parsedValue
} else if (currentSection.isNotEmpty()) {
sections[currentSection]?.put(key, parsedValue)
when {
currentProvider != null -> currentProvider[key] = parsedValue
currentModel != null -> currentModel[key] = parsedValue
currentSection.isNotEmpty() -> sections[currentSection]?.put(key, parsedValue)
}
}
}
}
}
// Don't forget the last provider if file ends with [[providers]] block
if (currentProvider != null) {
providers.add(currentProvider)
}
// Don't forget the last array-of-table entry if file ends with one
if (currentProvider != null) providers.add(currentProvider)
if (currentModel != null) models.add(currentModel)
return buildConfig(sections, providers)
return buildConfig(sections, providers, models)
}
private fun parseValue(valueStr: String, lineNum: Int): Any {
@@ -200,6 +211,7 @@ object ConfigLoader {
private fun buildConfig(
sections: Map<String, Map<String, Any>>,
providersList: List<Map<String, Any>> = emptyList(),
modelsList: List<Map<String, Any>> = emptyList(),
): CorrexConfig {
val serverSection = sections["server"] ?: emptyMap()
val tuiSection = sections["tui"] ?: emptyMap()
@@ -211,6 +223,7 @@ object ConfigLoader {
val toolsFileEditSection = sections["tools.file_edit"] ?: emptyMap()
val routerEmbedderSection = sections["router.embedder"] ?: emptyMap()
val routerL3Section = sections["router.l3"] ?: emptyMap()
val modelsSection = sections["models"] ?: emptyMap()
val server = ServerConfig(
host = asString(serverSection["host"], "localhost"),
@@ -347,6 +360,32 @@ object ConfigLoader {
l3 = l3,
)
val models = modelsList.mapNotNull { modelMap ->
val id = asString(modelMap["id"]) ?: return@mapNotNull null
val modelPath = asString(modelMap["model_path"]) ?: return@mapNotNull null
val contextSize = asInt(modelMap["context_size"], 8192)
@Suppress("UNCHECKED_CAST")
val params = (modelMap["params"] as? Map<String, Any>)
?.mapValues { it.value.toString() }
?: emptyMap()
val capabilities = parseCapabilities(modelMap["capabilities"])
ModelConfig(
id = id,
modelPath = modelPath,
contextSize = contextSize,
params = params,
capabilities = capabilities,
)
}
val modelsSettings = ModelsSettings(
defaultModel = asStringOrNull(modelsSection["default_model"]),
llamaServerBin = asString(modelsSection["llama_server_bin"], "llama-server"),
host = asString(modelsSection["host"], "127.0.0.1"),
port = asInt(modelsSection["port"], 10000),
)
return CorrexConfig(
server = server,
tui = tui,
@@ -354,6 +393,8 @@ object ConfigLoader {
tools = tools,
providers = providers,
router = router,
models = models,
modelsSettings = modelsSettings,
)
}
@@ -10,6 +10,8 @@ data class CorrexConfig(
val tools: ToolsConfig = ToolsConfig(),
val providers: List<ProviderConfig> = emptyList(),
val router: RouterConfig = RouterConfig(),
val models: List<ModelConfig> = emptyList(),
val modelsSettings: ModelsSettings = ModelsSettings(),
)
@Serializable
@@ -90,3 +92,20 @@ data class L3Config(
val dim: Int = 1536,
val bitWidth: Int = 4,
)
@Serializable
data class ModelConfig(
val id: String,
val modelPath: String,
val contextSize: Int = 8192,
val params: Map<String, String> = emptyMap(),
val capabilities: Map<String, Double> = emptyMap(),
)
@Serializable
data class ModelsSettings(
val defaultModel: String? = null,
val llamaServerBin: String = "llama-server",
val host: String = "127.0.0.1",
val port: Int = 10000,
)
@@ -270,4 +270,98 @@ class ConfigLoaderTest {
assertEquals(1536, result.router.l3.dim)
assertEquals(4, result.router.l3.bitWidth)
}
@Test
fun `parseToml parses models array-of-tables`() {
val toml = """
[models]
default_model = "mistral-7b"
llama_server_bin = "/usr/local/bin/llama-server"
host = "127.0.0.1"
port = 10001
[[models]]
id = "mistral-7b"
model_path = "/models/mistral-7b.gguf"
context_size = 4096
capabilities = { General = 1.0, Coding = 0.7, Reasoning = 0.6 }
[[models]]
id = "codellama-7b"
model_path = "/models/codellama-7b.gguf"
context_size = 8192
""".trimIndent()
val loader = ConfigLoader::class.java
val parseTomlMethod = loader.getDeclaredMethod("parseToml", String::class.java)
parseTomlMethod.isAccessible = true
val result = parseTomlMethod.invoke(ConfigLoader, toml) as CorrexConfig
assertEquals(2, result.models.size)
assertEquals("mistral-7b", result.models[0].id)
assertEquals("/models/mistral-7b.gguf", result.models[0].modelPath)
assertEquals(4096, result.models[0].contextSize)
assertEquals(3, result.models[0].capabilities.size)
assertEquals(1.0, result.models[0].capabilities["General"])
assertEquals(0.7, result.models[0].capabilities["Coding"])
assertEquals(0.6, result.models[0].capabilities["Reasoning"])
assertEquals("codellama-7b", result.models[1].id)
assertEquals("/models/codellama-7b.gguf", result.models[1].modelPath)
assertEquals(8192, result.models[1].contextSize)
assertEquals(0, result.models[1].capabilities.size)
assertEquals("mistral-7b", result.modelsSettings.defaultModel)
assertEquals("/usr/local/bin/llama-server", result.modelsSettings.llamaServerBin)
assertEquals("127.0.0.1", result.modelsSettings.host)
assertEquals(10001, result.modelsSettings.port)
}
@Test
fun `parseToml returns empty models list and default modelsSettings when sections absent`() {
val toml = """
[server]
host = "localhost"
""".trimIndent()
val loader = ConfigLoader::class.java
val parseTomlMethod = loader.getDeclaredMethod("parseToml", String::class.java)
parseTomlMethod.isAccessible = true
val result = parseTomlMethod.invoke(ConfigLoader, toml) as CorrexConfig
assertEquals(0, result.models.size)
assertEquals(null, result.modelsSettings.defaultModel)
assertEquals("llama-server", result.modelsSettings.llamaServerBin)
assertEquals("127.0.0.1", result.modelsSettings.host)
assertEquals(10000, result.modelsSettings.port)
}
@Test
fun `parseToml parses models alongside providers`() {
val toml = """
[[providers]]
id = "remote-provider"
type = "llamacpp"
model_id = "remote-model"
url = "http://192.168.1.10:10000"
[[models]]
id = "local-model"
model_path = "/models/local.gguf"
""".trimIndent()
val loader = ConfigLoader::class.java
val parseTomlMethod = loader.getDeclaredMethod("parseToml", String::class.java)
parseTomlMethod.isAccessible = true
val result = parseTomlMethod.invoke(ConfigLoader, toml) as CorrexConfig
assertEquals(1, result.providers.size)
assertEquals("remote-provider", result.providers[0].id)
assertEquals(1, result.models.size)
assertEquals("local-model", result.models[0].id)
assertEquals("/models/local.gguf", result.models[0].modelPath)
assertEquals(8192, result.models[0].contextSize)
}
}