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:
@@ -306,6 +306,7 @@ fun main() {
|
|||||||
topP = rc.narration.topP,
|
topP = rc.narration.topP,
|
||||||
maxTokens = rc.narration.maxTokens,
|
maxTokens = rc.narration.maxTokens,
|
||||||
),
|
),
|
||||||
|
narrationModelId = rc.narration.modelId,
|
||||||
)
|
)
|
||||||
return InfrastructureModule.createRouterFacade(
|
return InfrastructureModule.createRouterFacade(
|
||||||
eventStore = eventStore,
|
eventStore = eventStore,
|
||||||
|
|||||||
@@ -494,6 +494,7 @@ object ConfigLoader {
|
|||||||
topP = asDouble(routerNarrationSection["top_p"], 0.9),
|
topP = asDouble(routerNarrationSection["top_p"], 0.9),
|
||||||
maxTokens = asInt(routerNarrationSection["max_tokens"], 4096),
|
maxTokens = asInt(routerNarrationSection["max_tokens"], 4096),
|
||||||
maxPerRun = asInt(routerNarrationSection["max_per_run"], 100),
|
maxPerRun = asInt(routerNarrationSection["max_per_run"], 100),
|
||||||
|
modelId = asStringOrNull(routerNarrationSection["model_id"]),
|
||||||
)
|
)
|
||||||
|
|
||||||
val router = RouterConfig(
|
val router = RouterConfig(
|
||||||
|
|||||||
@@ -157,6 +157,7 @@ data class NarrationSettings(
|
|||||||
val topP: Double = 0.9,
|
val topP: Double = 0.9,
|
||||||
val maxTokens: Int = 4096,
|
val maxTokens: Int = 4096,
|
||||||
val maxPerRun: Int = 100,
|
val maxPerRun: Int = 100,
|
||||||
|
val modelId: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.correx.core.config
|
|||||||
|
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
|
||||||
class ConfigLoaderTest {
|
class ConfigLoaderTest {
|
||||||
@Test
|
@Test
|
||||||
@@ -519,4 +520,28 @@ class ConfigLoaderTest {
|
|||||||
assertEquals(180_000L, result.orchestration.stageTimeoutMs)
|
assertEquals(180_000L, result.orchestration.stageTimeoutMs)
|
||||||
assertEquals(2_000, result.orchestration.journalCompactionTokenThreshold)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,5 +8,6 @@ dependencies {
|
|||||||
implementation(project(":core:events"))
|
implementation(project(":core:events"))
|
||||||
implementation(project(":core:context"))
|
implementation(project(":core:context"))
|
||||||
implementation(project(":core:artifacts"))
|
implementation(project(":core:artifacts"))
|
||||||
|
implementation "org.slf4j:slf4j-api:2.0.16"
|
||||||
}
|
}
|
||||||
tasks.named("koverVerify").configure { enabled = false }
|
tasks.named("koverVerify").configure { enabled = false }
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import kotlin.time.Duration.Companion.seconds
|
|||||||
import kotlin.time.TimeMark
|
import kotlin.time.TimeMark
|
||||||
import kotlin.time.TimeSource
|
import kotlin.time.TimeSource
|
||||||
|
|
||||||
|
private val log = org.slf4j.LoggerFactory.getLogger(DefaultInferenceRouter::class.java)
|
||||||
|
|
||||||
private data class HealthEntry(
|
private data class HealthEntry(
|
||||||
val health: ProviderHealth,
|
val health: ProviderHealth,
|
||||||
val mark: TimeMark,
|
val mark: TimeMark,
|
||||||
@@ -57,4 +59,36 @@ class DefaultInferenceRouter(
|
|||||||
}
|
}
|
||||||
return selected
|
return selected
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun route(
|
||||||
|
stageId: StageId,
|
||||||
|
requiredCapabilities: Set<ModelCapability>,
|
||||||
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ class DefaultRouterFacade(
|
|||||||
|
|
||||||
val contextPack = routerContextBuilder.buildNarrationContext(state, trigger, config.tokenBudget)
|
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(
|
val inferenceRequest = InferenceRequest(
|
||||||
requestId = InferenceRequestId(UUID.randomUUID().toString()),
|
requestId = InferenceRequestId(UUID.randomUUID().toString()),
|
||||||
sessionId = sessionId,
|
sessionId = sessionId,
|
||||||
|
|||||||
@@ -24,4 +24,5 @@ data class RouterConfig(
|
|||||||
topP = 0.9,
|
topP = 0.9,
|
||||||
maxTokens = 4096,
|
maxTokens = 4096,
|
||||||
),
|
),
|
||||||
|
val narrationModelId: String? = null,
|
||||||
)
|
)
|
||||||
|
|||||||
+40
@@ -117,4 +117,44 @@ class DefaultInferenceRouterTest {
|
|||||||
val result = router.route(stage, setOf(ModelCapability.General))
|
val result = router.route(stage, setOf(ModelCapability.General))
|
||||||
assertSame(p2, result)
|
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]") })
|
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(
|
private fun emptyContextPack(): ContextPack = ContextPack(
|
||||||
id = ContextPackId("empty"),
|
id = ContextPackId("empty"),
|
||||||
sessionId = SessionId("unknown"),
|
sessionId = SessionId("unknown"),
|
||||||
|
|||||||
Reference in New Issue
Block a user