epic-12: after epic audit and init commit
This commit is contained in:
@@ -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"
|
||||
}
|
||||
+208
@@ -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
|
||||
}
|
||||
}
|
||||
+45
@@ -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>)
|
||||
+120
@@ -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", "")) }
|
||||
}
|
||||
}
|
||||
+26
@@ -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
|
||||
}
|
||||
+39
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user