feat: switchable router embedder and L3 backends via config

Adds [router.embedder] and [router.l3] sections to CorrexConfig with
backend selectors. Ships LlamaCppEmbedder that hits llama.cpp's
/embedding endpoint (handles OpenAI-compatible, simple, and array
response shapes; validates dimension). InfrastructureModule gains
createEmbedderFromConfig and createL3MemoryStoreFromConfig that
dispatch on backend value.

Defaults preserve current behavior (noop embedder + in-memory L3).
Switching to "llamacpp" / "turbovec" is a config-only change — no code
edits required. For turbovec backend, the bundled python sidecar
script is extracted from classpath to ~/.cache/correx/ on first use.
This commit is contained in:
2026-05-30 01:23:14 +04:00
parent 84a7568e15
commit d68c76ee3c
11 changed files with 496 additions and 2 deletions
+2
View File
@@ -11,10 +11,12 @@ dependencies {
implementation project(":core:inference")
implementation project(":core:approvals")
implementation project(":core:sessions")
implementation project(":core:config")
implementation project(":infrastructure:inference")
implementation project(":infrastructure:inference:commons")
implementation project(":infrastructure:inference:llama_cpp")
implementation project(":infrastructure:persistence")
implementation project(":infrastructure:router:turbovec")
implementation project(":infrastructure:tools")
implementation project(":infrastructure:tools:filesystem")
implementation project(":infrastructure:workflow")
@@ -22,6 +22,7 @@ dependencies {
implementation "io.ktor:ktor-client-content-negotiation:$ktor_version"
implementation "io.ktor:ktor-serialization-kotlinx-json:$ktor_version"
testImplementation "org.junit.jupiter:junit-jupiter"
testImplementation "io.ktor:ktor-client-mock:$ktor_version"
}
tasks.named("koverVerify").configure { enabled = false }
@@ -0,0 +1,124 @@
package com.correx.infrastructure.inference.llama.cpp
import com.correx.core.inference.Embedder
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
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
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.slf4j.LoggerFactory
private const val DEFAULT_REQUEST_TIMEOUT_MS = 60_000L
private fun defaultHttpClient(): HttpClient = HttpClient(CIO) {
install(ContentNegotiation) {
json(
Json {
ignoreUnknownKeys = true
isLenient = true
encodeDefaults = false
},
)
}
install(HttpTimeout) {
requestTimeoutMillis = DEFAULT_REQUEST_TIMEOUT_MS
}
}
private val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
encodeDefaults = true
}
private val log = LoggerFactory.getLogger(LlamaCppEmbedder::class.java)
class LlamaCppEmbedder(
private val url: String,
private val modelId: String? = null,
override val dimension: Int = 1536,
private val httpClient: HttpClient = defaultHttpClient(),
) : Embedder {
override suspend fun embed(text: String): FloatArray {
val request = EmbeddingRequest(
content = text,
model = modelId,
)
val encoded = json.encodeToString(request)
log.debug("sending embedding request to llama.cpp: {}", encoded)
val responseBody = try {
httpClient.post("$url/embedding") {
contentType(ContentType.Application.Json)
accept(ContentType.Application.Json)
setBody(encoded)
}.body<String>()
} catch (e: Exception) {
throw IllegalStateException("Failed to call embedding endpoint at $url/embedding: ${e.message}", e)
}
log.debug("got embedding response from llama.cpp: {}", responseBody)
val embedding = parseEmbeddingResponse(responseBody)
if (embedding.size != dimension) {
throw IllegalStateException(
"Embedding dimension mismatch: expected $dimension but got ${embedding.size}"
)
}
return embedding.toFloatArray()
}
private fun parseEmbeddingResponse(responseBody: String): List<Float> {
// Try OpenAI-compatible format first: {"data": [{"embedding": [...], "index": 0}]}
val openAiResult = runCatching {
val openAiResponse = json.decodeFromString<OpenAiEmbeddingResponse>(responseBody)
if (openAiResponse.data.isNotEmpty()) {
openAiResponse.data.first().embedding
} else {
emptyList<Float>()
}
}.getOrNull()
if (openAiResult != null && openAiResult.isNotEmpty()) {
return openAiResult
}
// Try simple format: {"embedding": [...]}
val simpleResult = runCatching {
val simpleResponse = json.decodeFromString<EmbeddingResponse>(responseBody)
simpleResponse.embedding ?: emptyList<Float>()
}.getOrNull()
if (simpleResult != null && simpleResult.isNotEmpty()) {
return simpleResult
}
// Try array format: [{"embedding": [...]}]
val arrayResult = runCatching {
val arrayResponse = json.decodeFromString<List<EmbeddingArrayResponse>>(responseBody)
if (arrayResponse.isNotEmpty()) {
arrayResponse.first().embedding
} else {
emptyList<Float>()
}
}.getOrNull()
if (arrayResult != null && arrayResult.isNotEmpty()) {
return arrayResult
}
throw IllegalStateException(
"Unexpected embedding response shape from $url. " +
"Response body (first 200 chars): ${responseBody.take(200)}"
)
}
}
@@ -0,0 +1,31 @@
package com.correx.infrastructure.inference.llama.cpp
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class EmbeddingRequest(
val content: String,
val model: String? = null,
)
@Serializable
data class EmbeddingResponse(
val embedding: List<Float>? = null,
)
@Serializable
data class EmbeddingArrayResponse(
val embedding: List<Float>,
)
@Serializable
data class OpenAiEmbeddingData(
val embedding: List<Float>,
val index: Int,
)
@Serializable
data class OpenAiEmbeddingResponse(
val data: List<OpenAiEmbeddingData>,
)
@@ -0,0 +1,139 @@
package com.correx.infrastructure.inference.llama.cpp
import io.ktor.client.HttpClient
import io.ktor.client.engine.mock.MockEngine
import io.ktor.client.engine.mock.respond
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.headersOf
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
class LlamaCppEmbedderTest {
@Test
fun `embed returns float array with correct dimension from OpenAI format`(): Unit = runBlocking {
val mockEngine = MockEngine { request ->
val responseBody = """
{
"data": [
{
"embedding": [0.1, 0.2, 0.3],
"index": 0
}
]
}
""".trimIndent()
respond(
content = responseBody,
headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString())
)
}
val embeddingUrl = "http://localhost:11000"
val httpClient = HttpClient(mockEngine)
val embedder = LlamaCppEmbedder(
url = embeddingUrl,
modelId = "test-model",
dimension = 3,
httpClient = httpClient,
)
val result = embedder.embed("test text")
assert(result.size == 3)
assert(kotlin.math.abs(result[0] - 0.1f) < 0.0001f)
assert(kotlin.math.abs(result[1] - 0.2f) < 0.0001f)
assert(kotlin.math.abs(result[2] - 0.3f) < 0.0001f)
}
@Test
fun `embed returns float array from simple embedding response format`(): Unit = runBlocking {
val mockEngine = MockEngine { request ->
val responseBody = """
{
"embedding": [0.5, 0.6, 0.7]
}
""".trimIndent()
respond(
content = responseBody,
headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString())
)
}
val embeddingUrl = "http://localhost:11000"
val httpClient = HttpClient(mockEngine)
val embedder = LlamaCppEmbedder(
url = embeddingUrl,
modelId = null,
dimension = 3,
httpClient = httpClient,
)
val result = embedder.embed("test text")
assert(result.size == 3)
assert(kotlin.math.abs(result[0] - 0.5f) < 0.0001f)
assert(kotlin.math.abs(result[1] - 0.6f) < 0.0001f)
assert(kotlin.math.abs(result[2] - 0.7f) < 0.0001f)
}
@Test
fun `embed throws on dimension mismatch`(): Unit = runBlocking {
val mockEngine = MockEngine { request ->
val responseBody = """
{
"embedding": [0.1, 0.2, 0.3, 0.4]
}
""".trimIndent()
respond(
content = responseBody,
headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString())
)
}
val embeddingUrl = "http://localhost:11000"
val httpClient = HttpClient(mockEngine)
val embedder = LlamaCppEmbedder(
url = embeddingUrl,
dimension = 3,
httpClient = httpClient,
)
assertThrows<IllegalStateException> {
runBlocking {
embedder.embed("test text")
}
}
}
@Test
fun `embed throws on unexpected response format`(): Unit = runBlocking {
val mockEngine = MockEngine { request ->
val responseBody = """
{
"unexpected_field": "value"
}
""".trimIndent()
respond(
content = responseBody,
headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString())
)
}
val embeddingUrl = "http://localhost:11000"
val httpClient = HttpClient(mockEngine)
val embedder = LlamaCppEmbedder(
url = embeddingUrl,
dimension = 3,
httpClient = httpClient,
)
assertThrows<IllegalStateException> {
runBlocking {
embedder.embed("test text")
}
}
}
}
@@ -6,6 +6,8 @@ import com.correx.core.approvals.DefaultApprovalRepository
import com.correx.core.artifacts.DefaultArtifactReducer
import com.correx.core.artifacts.repository.ArtifactRepository
import com.correx.core.artifactstore.ArtifactStore
import com.correx.core.config.EmbedderConfig
import com.correx.core.config.L3Config
import com.correx.core.events.EventDispatcher
import com.correx.core.events.stores.EventStore
import com.correx.core.inference.CapabilityScore
@@ -33,7 +35,10 @@ import com.correx.infrastructure.artifactscas.index.SqliteArtifactIndex
import com.correx.infrastructure.inference.DefaultProviderRegistry
import com.correx.infrastructure.inference.commons.ModelDescriptor
import com.correx.infrastructure.inference.commons.ResidencyMode
import com.correx.infrastructure.inference.llama.cpp.LlamaCppEmbedder
import com.correx.infrastructure.inference.llama.cpp.LlamaCppInferenceProvider
import com.correx.infrastructure.router.turbovec.TurboVecL3MemoryStore
import com.correx.infrastructure.router.turbovec.TurboVecSidecarConfig
import com.correx.infrastructure.persistence.SqliteEventStore
import com.correx.infrastructure.persistence.artifact.LiveArtifactRepository
import com.correx.infrastructure.tools.DefaultToolRegistry
@@ -49,6 +54,7 @@ import kotlinx.coroutines.runBlocking
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths
import java.nio.file.StandardCopyOption
import java.sql.DriverManager
@Suppress("TooManyFunctions")
@@ -137,6 +143,70 @@ object InfrastructureModule {
fun createInMemoryL3MemoryStore(): L3MemoryStore = InMemoryL3MemoryStore()
fun createLlamaCppEmbedder(config: EmbedderConfig): Embedder {
val url = config.url ?: throw IllegalArgumentException(
"EmbedderConfig with backend 'llamacpp' requires non-null 'url'"
)
return LlamaCppEmbedder(
url = url,
modelId = config.modelId,
dimension = config.dimension,
)
}
fun createTurboVecL3MemoryStore(config: L3Config): L3MemoryStore {
val scriptPath = config.scriptPath?.let { Paths.get(it) }
?: extractBundledTurboVecScript()
val persistPath = config.persistPath?.let { Paths.get(it) }
val sidecarConfig = TurboVecSidecarConfig(
pythonExecutable = config.pythonExecutable,
scriptPath = scriptPath,
dim = config.dim,
bitWidth = config.bitWidth,
persistPath = persistPath,
)
return TurboVecL3MemoryStore(sidecarConfig)
}
private fun extractBundledTurboVecScript(): Path {
val cacheDir = Paths.get(System.getProperty("user.home"), ".cache", "correx")
Files.createDirectories(cacheDir)
val targetPath = cacheDir.resolve("turbovec_sidecar.py")
val inputStream = InfrastructureModule::class.java.getResourceAsStream("/python/turbovec_sidecar.py")
?: throw IllegalStateException("Could not find bundled turbovec_sidecar.py in classpath")
inputStream.use { input ->
Files.copy(input, targetPath, StandardCopyOption.REPLACE_EXISTING)
}
return targetPath
}
fun createEmbedderFromConfig(config: EmbedderConfig): Embedder {
return when (config.backend) {
"noop" -> createNoopEmbedder(config.dimension)
"llamacpp" -> createLlamaCppEmbedder(config)
else -> throw IllegalArgumentException(
"Unknown embedder backend: '${config.backend}'. Supported: noop, llamacpp"
)
}
}
fun createL3MemoryStoreFromConfig(config: L3Config): L3MemoryStore {
return when (config.backend) {
"in_memory" -> createInMemoryL3MemoryStore()
"turbovec" -> createTurboVecL3MemoryStore(config)
else -> throw IllegalArgumentException(
"Unknown L3 backend: '${config.backend}'. Supported: in_memory, turbovec"
)
}
}
fun createRouterFacade(
eventStore: EventStore,
inferenceRouter: InferenceRouter,