epic-12: after epic audit and init commit
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
plugins {
|
||||
id 'java-library'
|
||||
id 'org.jetbrains.kotlin.jvm'
|
||||
id 'org.jetbrains.kotlin.plugin.serialization'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation project(':core:inference')
|
||||
implementation project(':core:events')
|
||||
testImplementation "org.junit.jupiter:junit-jupiter"
|
||||
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test"
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
plugins {
|
||||
id 'java-library'
|
||||
id 'org.jetbrains.kotlin.jvm'
|
||||
id 'org.jetbrains.kotlin.plugin.serialization'
|
||||
}
|
||||
|
||||
ext {
|
||||
ktor_version = '3.0.3'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation project(':core:inference')
|
||||
implementation project(':core:events')
|
||||
implementation project(':core:context')
|
||||
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"
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package com.correx.infrastructure.inference.commons
|
||||
|
||||
import com.correx.core.events.types.ProviderId
|
||||
import com.correx.core.inference.InferenceProvider
|
||||
import com.correx.core.inference.Tokenizer
|
||||
|
||||
/**
|
||||
* A wrapper around [InferenceProvider] that tracks model lifecycle.
|
||||
* Ensures the provider is tied to a specific [ModelManager] instance and [ModelDescriptor].
|
||||
*/
|
||||
interface ManagedInferenceProvider : InferenceProvider {
|
||||
/**
|
||||
* Unload the managed model.
|
||||
*/
|
||||
suspend fun unload()
|
||||
|
||||
/**
|
||||
* Check if the underlying model is still loaded.
|
||||
*/
|
||||
fun isLoaded(): Boolean
|
||||
|
||||
/**
|
||||
* Load a different model, replacing this one.
|
||||
*/
|
||||
suspend fun load(newDescriptor: ModelDescriptor): ManagedInferenceProvider
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package com.correx.infrastructure.inference.commons
|
||||
|
||||
import com.correx.core.inference.CapabilityScore
|
||||
|
||||
/**
|
||||
* Descriptor for a model instance with its configuration.
|
||||
*/
|
||||
data class ModelDescriptor(
|
||||
val modelId: String,
|
||||
val modelPath: String,
|
||||
val residencyMode: ResidencyMode,
|
||||
val idleTimeoutMs: Long = 60_000L,
|
||||
val contextSize: Int = 8192,
|
||||
val capabilities: Set<CapabilityScore> = emptySet(),
|
||||
)
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package com.correx.infrastructure.inference.commons
|
||||
|
||||
/**
|
||||
* Thrown when model loading fails during ModelManager.load().
|
||||
* No event is emitted on failure per event sourcing principles.
|
||||
*/
|
||||
class ModelLoadException(
|
||||
override val message: String,
|
||||
val modelId: String,
|
||||
override val cause: Throwable? = null,
|
||||
) : RuntimeException(message, cause)
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package com.correx.infrastructure.inference.commons
|
||||
|
||||
import com.correx.core.inference.InferenceProvider
|
||||
import com.correx.core.inference.ProviderHealth
|
||||
|
||||
/**
|
||||
* Manages the lifecycle of models.
|
||||
*/
|
||||
interface ModelManager {
|
||||
/**
|
||||
* Loads a model and returns an InferenceProvider instance.
|
||||
*
|
||||
* @param descriptor Model configuration
|
||||
* @return An InferenceProvider for the loaded model
|
||||
* @throws ModelLoadException if a different model is already loaded
|
||||
*/
|
||||
suspend fun load(descriptor: ModelDescriptor): InferenceProvider
|
||||
|
||||
/**
|
||||
* Unloads the currently loaded model.
|
||||
*
|
||||
* @param modelId The model ID to unload
|
||||
* @throws ModelLoadException if no model is currently loaded
|
||||
*/
|
||||
suspend fun unload(modelId: String)
|
||||
|
||||
/**
|
||||
* Returns the descriptor of the currently loaded model, or null if none.
|
||||
*/
|
||||
fun currentModel(): ModelDescriptor?
|
||||
|
||||
/**
|
||||
* Health check for the current model.
|
||||
*/
|
||||
fun healthCheck(): ProviderHealth
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package com.correx.infrastructure.inference.commons
|
||||
|
||||
/**
|
||||
* Residency modes for model lifecycle management.
|
||||
*/
|
||||
enum class ResidencyMode {
|
||||
PERSISTENT, // never unload
|
||||
DYNAMIC, // unload after idle timeout
|
||||
EPHEMERAL, // unload immediately after infer completes
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package com.correx.infrastructure.inference
|
||||
|
||||
import com.correx.core.events.types.ProviderId
|
||||
import com.correx.core.inference.InferenceProvider
|
||||
import com.correx.core.inference.ModelCapability
|
||||
import com.correx.core.inference.ProviderHealth
|
||||
import com.correx.core.inference.ProviderRegistry
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
|
||||
/**
|
||||
* In-memory [ProviderRegistry] backed by a [CopyOnWriteArrayList] for safe concurrent reads.
|
||||
*
|
||||
* Providers supplied at construction are registered immediately.
|
||||
* Additional providers may be registered at any time via [register].
|
||||
*/
|
||||
class DefaultProviderRegistry(
|
||||
initialProviders: List<InferenceProvider> = emptyList(),
|
||||
) : ProviderRegistry {
|
||||
|
||||
private val providers: CopyOnWriteArrayList<InferenceProvider> =
|
||||
CopyOnWriteArrayList(initialProviders)
|
||||
|
||||
override fun register(provider: InferenceProvider) {
|
||||
providers.add(provider)
|
||||
}
|
||||
|
||||
override fun resolve(capability: ModelCapability): List<InferenceProvider> =
|
||||
providers
|
||||
.filter { p -> p.capabilities().any { it.capability == capability } }
|
||||
.sortedByDescending { p ->
|
||||
p.capabilities().firstOrNull { it.capability == capability }?.score ?: 0.0
|
||||
}
|
||||
|
||||
override fun listAll(): List<InferenceProvider> = providers.toList()
|
||||
|
||||
override suspend fun healthCheckAll(): Map<ProviderId, ProviderHealth> =
|
||||
providers.associate { it.id to it.healthCheck() }
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package com.correx.infrastructure.inference
|
||||
|
||||
import com.correx.core.inference.InferenceProvider
|
||||
import com.correx.core.inference.ModelCapability
|
||||
import com.correx.core.inference.NoEligibleProviderException
|
||||
import com.correx.core.inference.RoutingStrategy
|
||||
import com.correx.core.events.types.StageId
|
||||
|
||||
/**
|
||||
* Selects the first [InferenceProvider] from [candidates] whose declared capabilities
|
||||
* cover all [requiredCapabilities].
|
||||
*
|
||||
* "First" means the list order as supplied by [ProviderRegistry.resolve] — callers that
|
||||
* want score-ordered selection should pass a score-sorted list.
|
||||
*
|
||||
* @throws NoEligibleProviderException if no candidate satisfies all required capabilities.
|
||||
*/
|
||||
class FirstAvailableRoutingStrategy : RoutingStrategy {
|
||||
override fun select(
|
||||
candidates: List<InferenceProvider>,
|
||||
requiredCapabilities: Set<ModelCapability>,
|
||||
): InferenceProvider {
|
||||
val declared = candidates.firstOrNull { provider ->
|
||||
val providerCapabilities = provider.capabilities().map { it.capability }.toSet()
|
||||
providerCapabilities.containsAll(requiredCapabilities)
|
||||
}
|
||||
return declared ?: throw NoEligibleProviderException(
|
||||
StageId("routing"),
|
||||
requiredCapabilities,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package com.correx.infrastructure.inference
|
||||
|
||||
object Module
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
package com.correx.infrastructure.inference
|
||||
|
||||
import com.correx.core.events.types.ProviderId
|
||||
import com.correx.core.inference.CapabilityScore
|
||||
import com.correx.core.inference.InferenceProvider
|
||||
import com.correx.core.inference.InferenceRequest
|
||||
import com.correx.core.inference.InferenceResponse
|
||||
import com.correx.core.inference.ModelCapability
|
||||
import com.correx.core.inference.ProviderHealth
|
||||
import com.correx.core.inference.Tokenizer
|
||||
import com.correx.core.inference.Token
|
||||
import com.correx.core.utils.TypeId
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class DefaultProviderRegistryTest {
|
||||
|
||||
private fun fakeProvider(
|
||||
id: String,
|
||||
vararg caps: Pair<ModelCapability, Double>,
|
||||
): InferenceProvider = object : InferenceProvider {
|
||||
override val id: ProviderId = TypeId(id)
|
||||
override val name: String = id
|
||||
override val tokenizer: Tokenizer = object : Tokenizer {
|
||||
override suspend fun tokenize(text: String): List<Token> = emptyList()
|
||||
override suspend fun countTokens(text: String): Int = 0
|
||||
}
|
||||
override suspend fun infer(request: InferenceRequest): InferenceResponse =
|
||||
error("not implemented in test")
|
||||
override suspend fun healthCheck(): ProviderHealth = ProviderHealth.Healthy
|
||||
override fun capabilities(): Set<CapabilityScore> =
|
||||
caps.map { (cap, score) -> CapabilityScore(cap, score) }.toSet()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `listAll returns all registered providers`() {
|
||||
val p1 = fakeProvider("p1", ModelCapability.Coding to 0.9)
|
||||
val p2 = fakeProvider("p2", ModelCapability.General to 0.8)
|
||||
val registry = DefaultProviderRegistry(listOf(p1, p2))
|
||||
assertEquals(2, registry.listAll().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `register adds provider`() {
|
||||
val registry = DefaultProviderRegistry()
|
||||
val p = fakeProvider("p1", ModelCapability.Coding to 0.9)
|
||||
registry.register(p)
|
||||
assertEquals(1, registry.listAll().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resolve returns providers with matching capability ordered by score desc`() {
|
||||
val low = fakeProvider("low", ModelCapability.Coding to 0.5)
|
||||
val high = fakeProvider("high", ModelCapability.Coding to 0.9)
|
||||
val other = fakeProvider("other", ModelCapability.General to 0.8)
|
||||
val registry = DefaultProviderRegistry(listOf(low, high, other))
|
||||
|
||||
val resolved = registry.resolve(ModelCapability.Coding)
|
||||
assertEquals(2, resolved.size)
|
||||
assertEquals("high", resolved.first().id.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resolve returns empty list when no provider matches`() {
|
||||
val registry = DefaultProviderRegistry(listOf(fakeProvider("p", ModelCapability.General to 1.0)))
|
||||
assertTrue(registry.resolve(ModelCapability.Reasoning).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `healthCheckAll returns health for all providers`() = runTest {
|
||||
val p1 = fakeProvider("p1", ModelCapability.Coding to 1.0)
|
||||
val p2 = fakeProvider("p2", ModelCapability.General to 1.0)
|
||||
val registry = DefaultProviderRegistry(listOf(p1, p2))
|
||||
val health = registry.healthCheckAll()
|
||||
assertEquals(2, health.size)
|
||||
assertTrue(health.values.all { it is ProviderHealth.Healthy })
|
||||
}
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
package com.correx.infrastructure.inference
|
||||
|
||||
import com.correx.core.events.types.ProviderId
|
||||
import com.correx.core.inference.CapabilityScore
|
||||
import com.correx.core.inference.InferenceProvider
|
||||
import com.correx.core.inference.InferenceRequest
|
||||
import com.correx.core.inference.InferenceResponse
|
||||
import com.correx.core.inference.ModelCapability
|
||||
import com.correx.core.inference.NoEligibleProviderException
|
||||
import com.correx.core.inference.ProviderHealth
|
||||
import com.correx.core.inference.Token
|
||||
import com.correx.core.inference.Tokenizer
|
||||
import com.correx.core.utils.TypeId
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.assertThrows
|
||||
|
||||
class FirstAvailableRoutingStrategyTest {
|
||||
|
||||
private val strategy = FirstAvailableRoutingStrategy()
|
||||
|
||||
private fun fakeProvider(id: String, vararg caps: ModelCapability): InferenceProvider =
|
||||
object : InferenceProvider {
|
||||
override val id: ProviderId = TypeId(id)
|
||||
override val name: String = id
|
||||
override val tokenizer: Tokenizer = object : Tokenizer {
|
||||
override suspend fun tokenize(text: String): List<Token> = emptyList()
|
||||
override suspend fun countTokens(text: String): Int = 0
|
||||
}
|
||||
override suspend fun infer(request: InferenceRequest): InferenceResponse =
|
||||
error("not implemented in test")
|
||||
override suspend fun healthCheck(): ProviderHealth = ProviderHealth.Healthy
|
||||
override fun capabilities(): Set<CapabilityScore> =
|
||||
caps.map { CapabilityScore(it, 1.0) }.toSet()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `selects first provider satisfying all required capabilities`() {
|
||||
val coding = fakeProvider("codingOnly", ModelCapability.Coding)
|
||||
val general = fakeProvider("generalAndCoding", ModelCapability.General, ModelCapability.Coding)
|
||||
val required = setOf(ModelCapability.Coding, ModelCapability.General)
|
||||
|
||||
val selected = strategy.select(listOf(coding, general), required)
|
||||
assertEquals("generalAndCoding", selected.id.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `selects first matching provider when multiple satisfy requirements`() {
|
||||
val p1 = fakeProvider("p1", ModelCapability.Coding)
|
||||
val p2 = fakeProvider("p2", ModelCapability.Coding)
|
||||
|
||||
val selected = strategy.select(listOf(p1, p2), setOf(ModelCapability.Coding))
|
||||
assertEquals("p1", selected.id.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `throws NoEligibleProviderException when no candidate satisfies required capabilities`() {
|
||||
val general = fakeProvider("general", ModelCapability.General)
|
||||
assertThrows<NoEligibleProviderException> {
|
||||
strategy.select(listOf(general), setOf(ModelCapability.Coding))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `throws NoEligibleProviderException when candidates list is empty`() {
|
||||
assertThrows<NoEligibleProviderException> {
|
||||
strategy.select(emptyList(), setOf(ModelCapability.Coding))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `selects any provider when required capabilities is empty`() {
|
||||
val p = fakeProvider("p", ModelCapability.General)
|
||||
val selected = strategy.select(listOf(p), emptySet())
|
||||
assertEquals("p", selected.id.value)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user