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
@@ -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(
@@ -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
@@ -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)
}
}
+1
View File
@@ -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 }
@@ -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<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 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,
@@ -24,4 +24,5 @@ data class RouterConfig(
topP = 0.9,
maxTokens = 4096,
),
val narrationModelId: String? = null,
)