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
@@ -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")
}
}
}
}