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