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:
@@ -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 }
|
||||
|
||||
+124
@@ -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)}"
|
||||
)
|
||||
}
|
||||
}
|
||||
+31
@@ -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>,
|
||||
)
|
||||
+139
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user