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