epic-12: after epic audit and init commit

This commit is contained in:
2026-05-16 11:42:00 +04:00
commit c77277af0b
461 changed files with 28958 additions and 0 deletions
@@ -0,0 +1,22 @@
plugins {
id 'java-library'
id 'org.jetbrains.kotlin.jvm'
id 'org.jetbrains.kotlin.plugin.serialization'
}
ext {
ktor_version = '3.0.3'
// Ensure parent version is available
ext.ktor_version = '3.0.3'
}
dependencies {
implementation project(':core:inference')
implementation project(':core:events')
implementation project(':core:context')
implementation project(':infrastructure:inference:commons')
implementation "io.ktor:ktor-client-core:$ktor_version"
implementation "io.ktor:ktor-client-cio:$ktor_version"
implementation "io.ktor:ktor-client-content-negotiation:$ktor_version"
implementation "io.ktor:ktor-serialization-kotlinx-json:$ktor_version"
}
@@ -0,0 +1,208 @@
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.core.events.stores.EventStore
import com.correx.core.events.types.ProviderId
import com.correx.core.events.types.SessionId
import com.correx.core.inference.InferenceProvider
import com.correx.core.inference.ProviderHealth
import com.correx.core.inference.Tokenizer
import com.correx.infrastructure.inference.commons.ManagedInferenceProvider
import com.correx.infrastructure.inference.commons.ModelDescriptor
import com.correx.infrastructure.inference.commons.ModelLoadException
import com.correx.infrastructure.inference.commons.ModelManager
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.get
import kotlinx.coroutines.delay
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.datetime.Clock
import java.io.File
/**
* Implementation of ModelManager that manages llama.cpp server process lifecycle.
*
* Enforces single-model-at-a-time policy via Mutex.
* Model swaps are performed via process restart (kill existing, spawn new with --model flag).
*
* Thread-safe: all public methods use Mutex for synchronization.
*/
@Suppress("LongParameterList", "TooGenericExceptionCaught", "UnusedPrivateProperty")
class DefaultModelManager(
private val llamaServerBin: String = "llama-server",
private val host: String = "127.0.0.1",
private val port: Int = 10000,
private val healthTimeoutMs: Long = 30_000L,
private val eventStore: EventStore,
private val httpClient: HttpClient,
private val eventDispatcher: EventDispatcher,
) : ModelManager {
private var currentProcess: LlamaProcess? = null
private var currentDescriptor: ModelDescriptor? = null
private val mutex = Mutex()
private val logDirPath = System.getProperty("user.home") + "/.local/share/correx/logs"
override suspend fun load(descriptor: ModelDescriptor): InferenceProvider = mutex.withLock {
// If same model already loaded, return existing provider
if (currentDescriptor?.modelId == descriptor.modelId) {
return@withLock createProvider(descriptor)
}
// Kill existing process if running
stopCurrentModelInternal()
// Spawn new process
val process = try {
val logFile = File(logDirPath, "llama-server.log")
val command = listOf(
llamaServerBin,
"--model", descriptor.modelPath,
"--ctx-size", descriptor.contextSize.toString(),
"--host", host,
"--port", port.toString(),
)
val lp = LlamaProcess(command, logFile)
lp.start()
lp
} catch (e: Exception) {
throw ModelLoadException("Failed to start llama-server: ${e.message}", descriptor.modelId, e)
}
// Health check polling
if (!waitForHealthy(process)) {
process.stop()
throw ModelLoadException(
"Health check timeout after ${healthTimeoutMs}ms",
descriptor.modelId,
)
}
// Set state
currentProcess = process
currentDescriptor = descriptor
// Emit event
val providerId = ProviderId("llama-cpp:${descriptor.modelId}")
eventDispatcher.emit(
payload = ModelLoadedEvent(
modelId = descriptor.modelId,
providerId = providerId,
sessionId = SessionId("correx:model-manager"),
),
sessionId = SessionId("correx:model-manager")
)
// Return provider
createProvider(descriptor)
}
override suspend fun unload(modelId: String) = mutex.withLock {
val process = currentProcess ?: throw ModelLoadException("No model currently loaded", modelId)
val descriptor = currentDescriptor ?: throw ModelLoadException("No model currently loaded", modelId)
if (descriptor.modelId != modelId) {
throw ModelLoadException("Model ID mismatch: expected ${descriptor.modelId}, got $modelId", modelId)
}
process.stop()
currentProcess = null
currentDescriptor = null
// Emit event
val providerId = ProviderId("llama-cpp")
eventDispatcher.emit(
payload = ModelUnloadedEvent(
modelId = modelId,
providerId = providerId,
sessionId = SessionId("correx:model-manager"),
cancellationReason = null,
),
sessionId = SessionId("correx:model-manager")
)
}
override fun currentModel(): ModelDescriptor? = currentDescriptor
override fun healthCheck(): ProviderHealth {
val process = currentProcess ?: return ProviderHealth.Unavailable("No model loaded")
return if (process.isAlive) {
ProviderHealth.Healthy
} else {
ProviderHealth.Unavailable("Process not running")
}
}
private suspend fun stopCurrentModelInternal() {
currentProcess?.stop()
currentProcess = null
currentDescriptor = null
}
@Suppress("UnusedParameter")
private suspend fun waitForHealthy(process: LlamaProcess): Boolean {
val endTime = Clock.System.now().toEpochMilliseconds() + healthTimeoutMs
while (Clock.System.now().toEpochMilliseconds() < endTime) {
try {
val response = httpClient.get("http://$host:$port/health").body<String>()
if (response.contains("\"status\":\"healthy\"") || response.contains("ok")) {
return true
}
} catch (_: Exception) {
// Continue polling
}
delay(POLL_INTERVAL)
}
return false
}
private fun createProvider(descriptor: ModelDescriptor): ManagedInferenceProvider {
val providerId = ProviderId("llama-cpp:${descriptor.modelId}")
val baseUrl = "http://$host:$port"
val delegate = LlamaCppInferenceProvider(
descriptor = descriptor,
baseUrl = baseUrl,
httpClient = httpClient,
)
return DefaultManagedInferenceProvider(delegate, this, descriptor, providerId)
}
private companion object {
private const val POLL_INTERVAL = 1000L
}
}
/**
* Implementation of [ManagedInferenceProvider] that tracks model lifecycle.
* Ensures the provider is tied to a specific [ModelManager] instance and [ModelDescriptor].
*/
class DefaultManagedInferenceProvider(
private val delegate: InferenceProvider,
private val manager: ModelManager,
private val descriptor: ModelDescriptor,
override val id: ProviderId,
) : ManagedInferenceProvider, InferenceProvider by delegate {
override val name: String = "Managed(${delegate.name})"
override val tokenizer: Tokenizer = delegate.tokenizer
override suspend fun unload() {
manager.unload(descriptor.modelId)
}
override fun isLoaded(): Boolean {
return manager.currentModel()?.modelId == descriptor.modelId
}
override suspend fun load(newDescriptor: ModelDescriptor): ManagedInferenceProvider {
val provider = manager.load(newDescriptor) as? ManagedInferenceProvider
?: throw ModelLoadException("Failed to load new model", newDescriptor.modelId)
return provider
}
}
@@ -0,0 +1,45 @@
package com.correx.infrastructure.inference.llama.cpp
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class ChatCompletionRequest(
val model: String,
val messages: List<ChatMessage>,
val temperature: Double,
@SerialName("top_p") val topP: Double,
@SerialName("max_tokens") val maxTokens: Int,
@SerialName("stop") val stopSequences: List<String> = emptyList(),
val seed: Long? = null,
val stream: Boolean = false,
)
@Serializable
data class ChatMessage(val role: String, val content: String)
@Serializable
data class ChatCompletionResponse(
val id: String,
val choices: List<Choice>,
val usage: Usage,
)
@Serializable
data class Choice(
val message: ChatMessage,
@SerialName("finish_reason") val finishReason: String,
)
@Serializable
data class Usage(
@SerialName("prompt_tokens") val promptTokens: Int,
@SerialName("completion_tokens") val completionTokens: Int,
@SerialName("total_tokens") val totalTokens: Int,
)
@Serializable
data class TokenizeRequest(val tokens: List<String>)
@Serializable
data class TokenizeResponse(val tokens: List<Int>)
@@ -0,0 +1,120 @@
package com.correx.infrastructure.inference.llama.cpp
import com.correx.core.context.model.ContextLayer
import com.correx.core.context.model.ContextPack
import com.correx.core.events.types.ProviderId
import com.correx.core.inference.CapabilityScore
import com.correx.core.inference.FinishReason
import com.correx.core.inference.InferenceProvider
import com.correx.core.inference.InferenceRequest
import com.correx.core.inference.InferenceResponse
import com.correx.core.inference.ProviderHealth
import com.correx.core.inference.TokenUsage
import com.correx.core.inference.Tokenizer
import com.correx.infrastructure.inference.commons.ModelDescriptor
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.accept
import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.contentType
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
private fun defaultHttpClient(): HttpClient = HttpClient(CIO) {
install(ContentNegotiation) {
json(
Json {
ignoreUnknownKeys = true
isLenient = true
encodeDefaults = false
},
)
}
}
@Suppress("TooGenericExceptionCaught", "MagicNumber")
class LlamaCppInferenceProvider(
private val descriptor: ModelDescriptor,
private val baseUrl: String = "http://127.0.0.1:10000",
private val httpClient: HttpClient = defaultHttpClient(),
) : InferenceProvider {
private val modelId: String = descriptor.modelId
override val id: ProviderId = ProviderId("llama-cpp:$modelId")
override val name: String = "LlamaCpp ($modelId)"
override val tokenizer: Tokenizer = LlamaCppTokenizer(baseUrl, httpClient)
override suspend fun infer(request: InferenceRequest): InferenceResponse {
val startTime = System.currentTimeMillis()
val messages = buildMessages(request.contextPack)
val body = ChatCompletionRequest(
model = modelId,
messages = messages,
temperature = request.generationConfig.temperature,
topP = request.generationConfig.topP,
maxTokens = request.generationConfig.maxTokens,
stopSequences = request.generationConfig.stopSequences,
seed = request.generationConfig.seed,
stream = false,
)
val response = httpClient.post("$baseUrl/v1/chat/completions") {
contentType(ContentType.Application.Json)
accept(ContentType.Application.Json)
setBody(body)
}.body<ChatCompletionResponse>()
val finishReason = response.choices.first().finishReason.lowercase().let {
if (it == "length") FinishReason.Length else FinishReason.Stop
}
return InferenceResponse(
requestId = request.requestId,
text = response.choices.first().message.content,
finishReason = finishReason,
tokensUsed = TokenUsage(
promptTokens = response.usage.promptTokens,
completionTokens = response.usage.completionTokens,
),
latencyMs = System.currentTimeMillis() - startTime,
)
}
override suspend fun healthCheck(): ProviderHealth = try {
val response = httpClient.get("$baseUrl/health")
if (response.status.value in 200..299) {
ProviderHealth.Healthy
} else {
ProviderHealth.Unavailable("Health check returned non-2xx status: ${response.status}")
}
} catch (e: Exception) {
ProviderHealth.Unavailable("Health check failed: ${e.message}")
}
override fun capabilities(): Set<CapabilityScore> = descriptor.capabilities
private fun buildMessages(contextPack: ContextPack): List<ChatMessage> {
val messages = contextPack.layers.flatMap { (layer, entries) ->
when (layer) {
ContextLayer.L0 -> entries.map { ChatMessage("system", it.content) }
ContextLayer.L1 -> entries.map { ChatMessage("user", it.content) }
ContextLayer.L2 -> entries.map {
ChatMessage(
role = if (it.sourceType == "assistant") "assistant" else "user",
content = it.content
)
}
else -> emptyList()
}
}
return messages.ifEmpty { listOf(ChatMessage("user", "")) }
}
}
@@ -0,0 +1,26 @@
package com.correx.infrastructure.inference.llama.cpp
import com.correx.core.inference.Token
import com.correx.core.inference.Tokenizer
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.accept
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.contentType
class LlamaCppTokenizer(
private val baseUrl: String = "http://127.0.0.1:10000",
private val httpClient: HttpClient,
) : Tokenizer {
override suspend fun tokenize(text: String): List<Token> =
httpClient.post("$baseUrl/tokenize") {
contentType(ContentType.Application.Json)
accept(ContentType.Application.Json)
setBody(TokenizeRequest(tokens = listOf(text)))
}.body<TokenizeResponse>().tokens.map { Token(it) }
override suspend fun countTokens(text: String): Int = tokenize(text).size
}
@@ -0,0 +1,39 @@
package com.correx.infrastructure.inference.llama.cpp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
/**
* Manages the lifecycle of a llama.cpp server process.
*/
class LlamaProcess(
private val command: List<String>,
private val logFile: File
) {
private var process: Process? = null
val isAlive: Boolean
get() = process?.isAlive ?: false
suspend fun start() = withContext(Dispatchers.IO) {
if (isAlive) return@withContext
logFile.parentFile?.mkdirs()
process = ProcessBuilder(command)
.redirectOutput(logFile)
.redirectErrorStream(true)
.start()
}
suspend fun stop() = withContext(Dispatchers.IO) {
process?.destroyForcibly()
try {
process?.waitFor()
} catch (_: Exception) {
// Process already dead or timeout
}
process = null
}
}