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 e5f40479..8c8dff1f 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 @@ -306,6 +306,7 @@ fun main() { topP = rc.narration.topP, maxTokens = rc.narration.maxTokens, ), + narrationModelId = rc.narration.modelId, ) return InfrastructureModule.createRouterFacade( eventStore = eventStore, 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 4ac60003..89f5b1b4 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 @@ -494,6 +494,7 @@ object ConfigLoader { topP = asDouble(routerNarrationSection["top_p"], 0.9), maxTokens = asInt(routerNarrationSection["max_tokens"], 4096), maxPerRun = asInt(routerNarrationSection["max_per_run"], 100), + modelId = asStringOrNull(routerNarrationSection["model_id"]), ) val router = RouterConfig( 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 03a3279f..04dcd8b5 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 @@ -157,6 +157,7 @@ data class NarrationSettings( val topP: Double = 0.9, val maxTokens: Int = 4096, val maxPerRun: Int = 100, + val modelId: String? = null, ) @Serializable 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 b1e840bd..47f13725 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 @@ -2,6 +2,7 @@ package com.correx.core.config import org.junit.jupiter.api.Test import kotlin.test.assertEquals +import kotlin.test.assertNull class ConfigLoaderTest { @Test @@ -519,4 +520,28 @@ class ConfigLoaderTest { assertEquals(180_000L, result.orchestration.stageTimeoutMs) assertEquals(2_000, result.orchestration.journalCompactionTokenThreshold) } + + @Test + fun `parseToml parses router narration model_id`() { + val toml = """ + [router.narration] + model_id = "llama-cpp:phi-3-mini" + """.trimIndent() + + val result = ConfigLoader.parseTomlForTest(toml) + + assertEquals("llama-cpp:phi-3-mini", result.router.narration.modelId) + } + + @Test + fun `parseToml narration model_id defaults to null when absent`() { + val toml = """ + [router.narration] + max_tokens = 2048 + """.trimIndent() + + val result = ConfigLoader.parseTomlForTest(toml) + + assertNull(result.router.narration.modelId) + } } diff --git a/core/inference/build.gradle b/core/inference/build.gradle index 8b6b0474..a184591c 100644 --- a/core/inference/build.gradle +++ b/core/inference/build.gradle @@ -8,5 +8,6 @@ dependencies { implementation(project(":core:events")) implementation(project(":core:context")) implementation(project(":core:artifacts")) + implementation "org.slf4j:slf4j-api:2.0.16" } tasks.named("koverVerify").configure { enabled = false } diff --git a/core/inference/src/main/kotlin/com/correx/core/inference/DefaultInferenceRouter.kt b/core/inference/src/main/kotlin/com/correx/core/inference/DefaultInferenceRouter.kt index 559d452b..f4dde2d5 100644 --- a/core/inference/src/main/kotlin/com/correx/core/inference/DefaultInferenceRouter.kt +++ b/core/inference/src/main/kotlin/com/correx/core/inference/DefaultInferenceRouter.kt @@ -9,6 +9,8 @@ import kotlin.time.Duration.Companion.seconds import kotlin.time.TimeMark import kotlin.time.TimeSource +private val log = org.slf4j.LoggerFactory.getLogger(DefaultInferenceRouter::class.java) + private data class HealthEntry( val health: ProviderHealth, val mark: TimeMark, @@ -57,4 +59,36 @@ class DefaultInferenceRouter( } return selected } + + override suspend fun route( + stageId: StageId, + requiredCapabilities: Set, + modelId: String?, + ): InferenceProvider { + if (modelId == null) return route(stageId, requiredCapabilities) + + val all = registry.listAll() + val healthy = all.filter { cachedHealth(it) !is ProviderHealth.Unavailable } + val matched = healthy.firstOrNull { it.id.value == modelId } + if (matched == null) { + log.warn( + "narration model_id '{}' matched no healthy provider — falling back to capability routing", + modelId, + ) + return route(stageId, requiredCapabilities) + } + when (val postHealth = matched.healthCheck()) { + is ProviderHealth.Unavailable -> { + log.warn( + "narration model_id '{}' provider failed post-select health check ({})" + + " — falling back to capability routing", + modelId, + postHealth.reason, + ) + return route(stageId, requiredCapabilities) + } + else -> Unit + } + return matched + } } diff --git a/core/router/src/main/kotlin/com/correx/core/router/RouterFacade.kt b/core/router/src/main/kotlin/com/correx/core/router/RouterFacade.kt index b76f9ab7..9eb974c2 100644 --- a/core/router/src/main/kotlin/com/correx/core/router/RouterFacade.kt +++ b/core/router/src/main/kotlin/com/correx/core/router/RouterFacade.kt @@ -175,7 +175,7 @@ class DefaultRouterFacade( val contextPack = routerContextBuilder.buildNarrationContext(state, trigger, config.tokenBudget) - val provider = inferenceRouter.route(effectiveStageId, setOf(ModelCapability.General)) + val provider = inferenceRouter.route(effectiveStageId, setOf(ModelCapability.General), config.narrationModelId) val inferenceRequest = InferenceRequest( requestId = InferenceRequestId(UUID.randomUUID().toString()), sessionId = sessionId, diff --git a/core/router/src/main/kotlin/com/correx/core/router/model/RouterConfig.kt b/core/router/src/main/kotlin/com/correx/core/router/model/RouterConfig.kt index 7c697061..c757aed6 100644 --- a/core/router/src/main/kotlin/com/correx/core/router/model/RouterConfig.kt +++ b/core/router/src/main/kotlin/com/correx/core/router/model/RouterConfig.kt @@ -24,4 +24,5 @@ data class RouterConfig( topP = 0.9, maxTokens = 4096, ), + val narrationModelId: String? = null, ) diff --git a/testing/contracts/src/test/kotlin/com/correx/testing/contracts/model/inference/DefaultInferenceRouterTest.kt b/testing/contracts/src/test/kotlin/com/correx/testing/contracts/model/inference/DefaultInferenceRouterTest.kt index 1c4ccce8..5e405efb 100644 --- a/testing/contracts/src/test/kotlin/com/correx/testing/contracts/model/inference/DefaultInferenceRouterTest.kt +++ b/testing/contracts/src/test/kotlin/com/correx/testing/contracts/model/inference/DefaultInferenceRouterTest.kt @@ -117,4 +117,44 @@ class DefaultInferenceRouterTest { val result = router.route(stage, setOf(ModelCapability.General)) assertSame(p2, result) } + + // ── 3-arg route with modelId ─────────────────────────────────────────── + + @Test + fun `3-arg route selects provider by exact id match`(): Unit = runBlocking { + val p1 = provider("llama-cpp:fast-model", ModelCapability.General) + val p2 = provider("llama-cpp:big-model", ModelCapability.General) + val router = DefaultInferenceRouter(registryOf(p1, p2), firstStrategy()) + val result = router.route(stage, setOf(ModelCapability.General), "llama-cpp:fast-model") + assertSame(p1, result) + } + + @Test + fun `3-arg route falls back to capability routing when modelId matches no provider`(): Unit = runBlocking { + val p1 = provider("llama-cpp:only-one", ModelCapability.General) + val router = DefaultInferenceRouter(registryOf(p1), firstStrategy()) + val result = router.route(stage, setOf(ModelCapability.General), "nonexistent-model") + assertSame(p1, result) + } + + @Test + fun `3-arg route with null modelId delegates to 2-arg form`(): Unit = runBlocking { + val p = provider("llama-cpp:model", ModelCapability.General) + val router = DefaultInferenceRouter(registryOf(p), firstStrategy()) + val result = router.route(stage, setOf(ModelCapability.General), null) + assertSame(p, result) + } + + @Test + fun `3-arg route falls back to capability routing when matched provider is unhealthy`(): Unit = runBlocking { + val sick = MockInferenceProvider( + id = ProviderId("llama-cpp:sick"), + declaredCapabilities = setOf(CapabilityScore(ModelCapability.General, 1.0)), + health = ProviderHealth.Unavailable("down"), + ) + val healthy = provider("llama-cpp:healthy", ModelCapability.General) + val router = DefaultInferenceRouter(registryOf(sick, healthy), firstStrategy()) + val result = router.route(stage, setOf(ModelCapability.General), "llama-cpp:sick") + assertSame(healthy, result) + } } diff --git a/testing/deterministic/src/test/kotlin/RouterFacadeTest.kt b/testing/deterministic/src/test/kotlin/RouterFacadeTest.kt index cb1e3b55..f3b2b6c0 100644 --- a/testing/deterministic/src/test/kotlin/RouterFacadeTest.kt +++ b/testing/deterministic/src/test/kotlin/RouterFacadeTest.kt @@ -1186,6 +1186,93 @@ class RouterFacadeTest { assertTrue(l3Entries.any { it.content.contains("[recalled memory]") }) } + // -------------------------------------------------------------------------- + // Narration model ID routing + // -------------------------------------------------------------------------- + + @Test + fun `narrate passes narrationModelId to inferenceRouter 3-arg route`(): Unit = runBlocking { + val capturedModelId = mutableListOf() + val mockInferenceRouter = object : InferenceRouter { + override suspend fun route( + stageId: StageId, + requiredCapabilities: Set, + ): InferenceProvider = mockProvider("narration text") + + override suspend fun route( + stageId: StageId, + requiredCapabilities: Set, + modelId: String?, + ): InferenceProvider { + capturedModelId.add(modelId) + return mockProvider("narration text") + } + } + val facade = DefaultRouterFacade( + routerRepository = object : RouterRepository { + override suspend fun getRouterState(sessionId: SessionId): RouterState = RouterState() + }, + routerContextBuilder = object : RouterContextBuilder { + override suspend fun build(state: RouterState, budget: TokenBudget): ContextPack = emptyContextPack() + }, + inferenceRouter = mockInferenceRouter, + eventStore = mockEventStore(), + config = RouterConfig( + tokenBudget = TokenBudget(limit = 5000), + narrationModelId = "llama-cpp:phi-3-mini", + ), + embedder = NoopEmbedder(dimension = 8), + l3MemoryStore = InMemoryL3MemoryStore(), + ) + facade.narrate( + sessionId = SessionId("test-session"), + trigger = com.correx.core.router.model.NarrationTrigger( + kind = "test", + instruction = "describe what happened", + ), + ) + assertEquals(1, capturedModelId.size) + assertEquals("llama-cpp:phi-3-mini", capturedModelId[0]) + } + + @Test + fun `onUserInput does NOT pass modelId — uses 2-arg route`(): Unit = runBlocking { + var threeArgCallCount = 0 + val mockInferenceRouter = object : InferenceRouter { + override suspend fun route( + stageId: StageId, + requiredCapabilities: Set, + ): InferenceProvider = mockProvider("response") + + override suspend fun route( + stageId: StageId, + requiredCapabilities: Set, + modelId: String?, + ): InferenceProvider { + threeArgCallCount++ + return mockProvider("response") + } + } + val facade = DefaultRouterFacade( + routerRepository = object : RouterRepository { + override suspend fun getRouterState(sessionId: SessionId): RouterState = RouterState() + }, + routerContextBuilder = object : RouterContextBuilder { + override suspend fun build(state: RouterState, budget: TokenBudget): ContextPack = emptyContextPack() + }, + inferenceRouter = mockInferenceRouter, + eventStore = mockEventStore(), + config = RouterConfig( + tokenBudget = TokenBudget(limit = 5000), + narrationModelId = "llama-cpp:phi-3-mini", + ), + embedder = NoopEmbedder(dimension = 8), + l3MemoryStore = InMemoryL3MemoryStore(), + ) + facade.onUserInput(sessionId = SessionId("test-session"), input = "Hello!") + assertEquals(0, threeArgCallCount, "onUserInput must not call 3-arg route()") + } + private fun emptyContextPack(): ContextPack = ContextPack( id = ContextPackId("empty"), sessionId = SessionId("unknown"),