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
+12
View File
@@ -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"
}
@@ -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
}
@@ -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(),
)
@@ -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)
@@ -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
}
@@ -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"
}
@@ -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
}
}
@@ -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() }
}
@@ -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
@@ -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 })
}
}
@@ -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)
}
}