feat(router,inference,config): pin narration to a configured model_id

[router.narration] model_id selects which provider handles narration turns
instead of generic capability routing — lets a small/fast model own narration
while the main model keeps CHAT/STEERING.

- NarrationSettings.modelId parsed from TOML, threaded via RouterConfig
  .narrationModelId into DefaultRouterFacade.narrate (3-arg route)
- DefaultInferenceRouter: exact-id match against healthy providers, with a
  post-select health check; any miss logs WARN and falls back to capability
  routing (never fails the narration turn)
- onUserInput unchanged — only narrate uses the pinned model (tested)
This commit is contained in:
2026-06-10 20:53:28 +04:00
parent cc6b402a23
commit 883e23dec7
10 changed files with 192 additions and 1 deletions
@@ -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<String?>()
val mockInferenceRouter = object : InferenceRouter {
override suspend fun route(
stageId: StageId,
requiredCapabilities: Set<ModelCapability>,
): InferenceProvider = mockProvider("narration text")
override suspend fun route(
stageId: StageId,
requiredCapabilities: Set<ModelCapability>,
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<ModelCapability>,
): InferenceProvider = mockProvider("response")
override suspend fun route(
stageId: StageId,
requiredCapabilities: Set<ModelCapability>,
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"),