From d68c76ee3c526a316bbf6be42852b8d41e5c337d Mon Sep 17 00:00:00 2001 From: kami Date: Sat, 30 May 2026 01:23:14 +0400 Subject: [PATCH] feat: switchable router embedder and L3 backends via config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../kotlin/com/correx/apps/server/Main.kt | 8 +- .../com/correx/core/config/ConfigLoader.kt | 25 ++++ .../com/correx/core/config/CorrexConfig.kt | 25 ++++ .../correx/core/config/ConfigLoaderTest.kt | 58 ++++++++ docs/sample-config.toml | 15 ++ infrastructure/build.gradle | 2 + .../inference/llama_cpp/build.gradle | 1 + .../inference/llama/cpp/LlamaCppEmbedder.kt | 124 ++++++++++++++++ .../llama/cpp/LlamaCppEmbeddingApiModels.kt | 31 ++++ .../llama/cpp/LlamaCppEmbedderTest.kt | 139 ++++++++++++++++++ .../infrastructure/InfrastructureModule.kt | 70 +++++++++ 11 files changed, 496 insertions(+), 2 deletions(-) create mode 100644 infrastructure/inference/llama_cpp/src/main/kotlin/com/correx/infrastructure/inference/llama/cpp/LlamaCppEmbedder.kt create mode 100644 infrastructure/inference/llama_cpp/src/main/kotlin/com/correx/infrastructure/inference/llama/cpp/LlamaCppEmbeddingApiModels.kt create mode 100644 infrastructure/inference/llama_cpp/src/test/kotlin/com/correx/infrastructure/inference/llama/cpp/LlamaCppEmbedderTest.kt diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt index 2ca7b325..0db6e038 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt @@ -124,12 +124,16 @@ fun main() { sandboxRoot = sandboxRoot, defaultSystemPromptPath = toolsConfig.defaultSystemPromptPath, ) - val routerConfig = RouterConfig() + val routerConfig = correxConfig.router + val embedder = InfrastructureModule.createEmbedderFromConfig(routerConfig.embedder) + val l3MemoryStore = InfrastructureModule.createL3MemoryStoreFromConfig(routerConfig.l3) val routerFacade = InfrastructureModule.createRouterFacade( eventStore = eventStore, inferenceRouter = inferenceRouter, - config = routerConfig, + config = RouterConfig(), tokenizer = llamaProvider.tokenizer, + embedder = embedder, + l3MemoryStore = l3MemoryStore, ) val module = ServerModule( orchestrator = orchestrator, diff --git a/core/config/src/main/kotlin/com/correx/core/config/ConfigLoader.kt b/core/config/src/main/kotlin/com/correx/core/config/ConfigLoader.kt index ff467166..400fb5be 100644 --- a/core/config/src/main/kotlin/com/correx/core/config/ConfigLoader.kt +++ b/core/config/src/main/kotlin/com/correx/core/config/ConfigLoader.kt @@ -210,6 +210,9 @@ object ConfigLoader { val toolsFileReadSection = sections["tools.file_read"] ?: emptyMap() val toolsFileWriteSection = sections["tools.file_write"] ?: emptyMap() val toolsFileEditSection = sections["tools.file_edit"] ?: emptyMap() + val routerSection = sections["router"] ?: emptyMap() + val routerEmbedderSection = sections["router.embedder"] ?: emptyMap() + val routerL3Section = sections["router.l3"] ?: emptyMap() val server = ServerConfig( host = asString(serverSection["host"], "localhost"), @@ -322,6 +325,27 @@ object ConfigLoader { ) } + val embedder = EmbedderConfig( + backend = asString(routerEmbedderSection["backend"], "noop"), + dimension = asInt(routerEmbedderSection["dimension"], 1536), + url = asStringOrNull(routerEmbedderSection["url"]), + modelId = asStringOrNull(routerEmbedderSection["model_id"]), + ) + + val l3 = L3Config( + backend = asString(routerL3Section["backend"], "in_memory"), + persistPath = asStringOrNull(routerL3Section["persist_path"]), + pythonExecutable = asString(routerL3Section["python_executable"], "python3"), + scriptPath = asStringOrNull(routerL3Section["script_path"]), + dim = asInt(routerL3Section["dim"], 1536), + bitWidth = asInt(routerL3Section["bit_width"], 4), + ) + + val router = RouterConfig( + embedder = embedder, + l3 = l3, + ) + return CorrexConfig( server = server, tui = tui, @@ -329,6 +353,7 @@ object ConfigLoader { approval = approval, tools = tools, providers = providers, + router = router, ) } diff --git a/core/config/src/main/kotlin/com/correx/core/config/CorrexConfig.kt b/core/config/src/main/kotlin/com/correx/core/config/CorrexConfig.kt index 2e37a008..5912b757 100644 --- a/core/config/src/main/kotlin/com/correx/core/config/CorrexConfig.kt +++ b/core/config/src/main/kotlin/com/correx/core/config/CorrexConfig.kt @@ -10,6 +10,7 @@ data class CorrexConfig( val approval: ApprovalConfig = ApprovalConfig(), val tools: ToolsConfig = ToolsConfig(), val providers: List = emptyList(), + val router: RouterConfig = RouterConfig(), ) @Serializable @@ -55,3 +56,27 @@ data class ProviderConfig( val url: String = "http://127.0.0.1:10000", val capabilities: Map = emptyMap(), ) + +@Serializable +data class RouterConfig( + val embedder: EmbedderConfig = EmbedderConfig(), + val l3: L3Config = L3Config(), +) + +@Serializable +data class EmbedderConfig( + val backend: String = "noop", + val dimension: Int = 1536, + val url: String? = null, + val modelId: String? = null, +) + +@Serializable +data class L3Config( + val backend: String = "in_memory", + val persistPath: String? = null, + val pythonExecutable: String = "python3", + val scriptPath: String? = null, + val dim: Int = 1536, + val bitWidth: Int = 4, +) diff --git a/core/config/src/test/kotlin/com/correx/core/config/ConfigLoaderTest.kt b/core/config/src/test/kotlin/com/correx/core/config/ConfigLoaderTest.kt index 0eb0bf04..0ac550e7 100644 --- a/core/config/src/test/kotlin/com/correx/core/config/ConfigLoaderTest.kt +++ b/core/config/src/test/kotlin/com/correx/core/config/ConfigLoaderTest.kt @@ -217,4 +217,62 @@ class ConfigLoaderTest { assertEquals(1.0, result.providers[0].capabilities["General"]) assertEquals(0.7, result.providers[0].capabilities["Coding"]) } + + @Test + fun `parseToml parses router embedder and l3 config`() { + val toml = """ + [router.embedder] + backend = "llamacpp" + dimension = 1536 + url = "http://localhost:11000" + model_id = "nomic-embed-text" + + [router.l3] + backend = "turbovec" + persist_path = "/tmp/router/l3.tq" + python_executable = "python3" + dim = 1536 + bit_width = 4 + """.trimIndent() + + val loader = ConfigLoader::class.java + val parseTomlMethod = loader.getDeclaredMethod("parseToml", String::class.java) + parseTomlMethod.isAccessible = true + val result = parseTomlMethod.invoke(ConfigLoader, toml) as CorrexConfig + + assertEquals("llamacpp", result.router.embedder.backend) + assertEquals(1536, result.router.embedder.dimension) + assertEquals("http://localhost:11000", result.router.embedder.url) + assertEquals("nomic-embed-text", result.router.embedder.modelId) + + assertEquals("turbovec", result.router.l3.backend) + assertEquals("/tmp/router/l3.tq", result.router.l3.persistPath) + assertEquals("python3", result.router.l3.pythonExecutable) + assertEquals(1536, result.router.l3.dim) + assertEquals(4, result.router.l3.bitWidth) + } + + @Test + fun `parseToml applies router defaults when sections absent`() { + val toml = """ + [server] + host = "localhost" + """.trimIndent() + + val loader = ConfigLoader::class.java + val parseTomlMethod = loader.getDeclaredMethod("parseToml", String::class.java) + parseTomlMethod.isAccessible = true + val result = parseTomlMethod.invoke(ConfigLoader, toml) as CorrexConfig + + assertEquals("noop", result.router.embedder.backend) + assertEquals(1536, result.router.embedder.dimension) + assertEquals(null, result.router.embedder.url) + assertEquals(null, result.router.embedder.modelId) + + assertEquals("in_memory", result.router.l3.backend) + assertEquals(null, result.router.l3.persistPath) + assertEquals("python3", result.router.l3.pythonExecutable) + assertEquals(1536, result.router.l3.dim) + assertEquals(4, result.router.l3.bitWidth) + } } diff --git a/docs/sample-config.toml b/docs/sample-config.toml index 4f7ecdaa..734a5bae 100644 --- a/docs/sample-config.toml +++ b/docs/sample-config.toml @@ -50,3 +50,18 @@ capabilities = { General = 1.0, Coding = 0.7, Reasoning = 0.6, Summarization = 0 # model_path = "~/models/neural-chat-7b-gguf/model.gguf" # url = "http://127.0.0.1:10001" # capabilities = { General = 0.9, Coding = 0.8, Reasoning = 0.7, Summarization = 0.75, ToolCalling = 0.6 } + +# Router configuration (optional, defaults shown below) +[router.embedder] +backend = "noop" # or "llamacpp" +dimension = 1536 +# url = "http://127.0.0.1:11000" +# model_id = "nomic-embed-text" + +[router.l3] +backend = "in_memory" # or "turbovec" +# persist_path = "~/.config/correx/router/l3/index.tq" +# python_executable = "python3" +# script_path = "~/.config/correx/python/turbovec_sidecar.py" +# dim = 1536 +# bit_width = 4 diff --git a/infrastructure/build.gradle b/infrastructure/build.gradle index d3796ac8..bbf4caa8 100644 --- a/infrastructure/build.gradle +++ b/infrastructure/build.gradle @@ -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") diff --git a/infrastructure/inference/llama_cpp/build.gradle b/infrastructure/inference/llama_cpp/build.gradle index dd48ce3e..1ee5603c 100644 --- a/infrastructure/inference/llama_cpp/build.gradle +++ b/infrastructure/inference/llama_cpp/build.gradle @@ -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 } diff --git a/infrastructure/inference/llama_cpp/src/main/kotlin/com/correx/infrastructure/inference/llama/cpp/LlamaCppEmbedder.kt b/infrastructure/inference/llama_cpp/src/main/kotlin/com/correx/infrastructure/inference/llama/cpp/LlamaCppEmbedder.kt new file mode 100644 index 00000000..e621eda2 --- /dev/null +++ b/infrastructure/inference/llama_cpp/src/main/kotlin/com/correx/infrastructure/inference/llama/cpp/LlamaCppEmbedder.kt @@ -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() + } 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 { + // Try OpenAI-compatible format first: {"data": [{"embedding": [...], "index": 0}]} + val openAiResult = runCatching { + val openAiResponse = json.decodeFromString(responseBody) + if (openAiResponse.data.isNotEmpty()) { + openAiResponse.data.first().embedding + } else { + emptyList() + } + }.getOrNull() + if (openAiResult != null && openAiResult.isNotEmpty()) { + return openAiResult + } + + // Try simple format: {"embedding": [...]} + val simpleResult = runCatching { + val simpleResponse = json.decodeFromString(responseBody) + simpleResponse.embedding ?: emptyList() + }.getOrNull() + if (simpleResult != null && simpleResult.isNotEmpty()) { + return simpleResult + } + + // Try array format: [{"embedding": [...]}] + val arrayResult = runCatching { + val arrayResponse = json.decodeFromString>(responseBody) + if (arrayResponse.isNotEmpty()) { + arrayResponse.first().embedding + } else { + emptyList() + } + }.getOrNull() + if (arrayResult != null && arrayResult.isNotEmpty()) { + return arrayResult + } + + throw IllegalStateException( + "Unexpected embedding response shape from $url. " + + "Response body (first 200 chars): ${responseBody.take(200)}" + ) + } +} diff --git a/infrastructure/inference/llama_cpp/src/main/kotlin/com/correx/infrastructure/inference/llama/cpp/LlamaCppEmbeddingApiModels.kt b/infrastructure/inference/llama_cpp/src/main/kotlin/com/correx/infrastructure/inference/llama/cpp/LlamaCppEmbeddingApiModels.kt new file mode 100644 index 00000000..d90707e6 --- /dev/null +++ b/infrastructure/inference/llama_cpp/src/main/kotlin/com/correx/infrastructure/inference/llama/cpp/LlamaCppEmbeddingApiModels.kt @@ -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? = null, +) + +@Serializable +data class EmbeddingArrayResponse( + val embedding: List, +) + +@Serializable +data class OpenAiEmbeddingData( + val embedding: List, + val index: Int, +) + +@Serializable +data class OpenAiEmbeddingResponse( + val data: List, +) diff --git a/infrastructure/inference/llama_cpp/src/test/kotlin/com/correx/infrastructure/inference/llama/cpp/LlamaCppEmbedderTest.kt b/infrastructure/inference/llama_cpp/src/test/kotlin/com/correx/infrastructure/inference/llama/cpp/LlamaCppEmbedderTest.kt new file mode 100644 index 00000000..60856bce --- /dev/null +++ b/infrastructure/inference/llama_cpp/src/test/kotlin/com/correx/infrastructure/inference/llama/cpp/LlamaCppEmbedderTest.kt @@ -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 { + 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 { + runBlocking { + embedder.embed("test text") + } + } + } +} diff --git a/infrastructure/src/main/kotlin/com/correx/infrastructure/InfrastructureModule.kt b/infrastructure/src/main/kotlin/com/correx/infrastructure/InfrastructureModule.kt index c9d549ad..5b1a0340 100644 --- a/infrastructure/src/main/kotlin/com/correx/infrastructure/InfrastructureModule.kt +++ b/infrastructure/src/main/kotlin/com/correx/infrastructure/InfrastructureModule.kt @@ -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,