feat(llm-router): expose provider in RoutingDecision#57
Conversation
Add `provider: Option<String>` to `RoutingDecision` and populate it from the chosen model's `ModelRegistration.provider` after the routing decision is made. Why: agent harnesses dispatching by `provider::<name>::stream_assistant` need the provider name to route the actual call. Today they have to do a second `models:` state lookup or parse a namespaced model id themselves. The router already loads the full models list in `decide`, so this is a free two-line enrichment. - types.rs: new optional field, skip_serializing_if for backward compat - decide.rs: enrichment after `decide()` — only fills when not already set by the decision and a registration is found - router.rs: 7 RoutingDecision construction sites updated with `provider: None`; two new tests cover serde behavior Field is optional so existing consumers see no shape change. Models referenced before registration (e.g. policy `model: "auto"` resolved by classifier to an unregistered model) still get an empty provider, which consumers can fall back on by parsing namespaced model ids. All 32 tests pass; clippy + fmt clean.
📝 WalkthroughWalkthroughThe pull request adds provider information enrichment to routing decisions. A new optional Changes
Estimated code review effort🎯 2 (Simple) | ⏱️ ~12 minutes Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
…ision Tier 3 prose described provider derivation from `response.provider` — that field doesn't exist in stock llm-router yet. The PR adding it is open at iii-hq/workers#57. Once merged, the namespace-split fallback becomes a fallback rather than the primary path.
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (1)
llm-router/src/router.rs (1)
628-662: Add one behavioral test for provider enrichment path.These tests validate serde shape, but not the new enrichment behavior from
llm-router/src/functions/decide.rs(Line 80-85). Please add a case that asserts provider is copied fromModelRegistrationfor a selected model.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@llm-router/src/router.rs` around lines 628 - 662, Add a behavioral test that exercises the enrichment path in llm-router/src/functions/decide.rs (the function that produces a RoutingDecision for a selected model) to assert that when a ModelRegistration has provider = Some("...") the returned RoutingDecision copies that provider; create a ModelRegistration with provider set, invoke the decision function (the one in decide.rs that selects/returns a RoutingDecision), and assert the resulting RoutingDecision.provider == Some("your-provider") to ensure provider enrichment from ModelRegistration works (use the existing RoutingDecision and ModelRegistration types to locate fields).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@llm-router/src/functions/decide.rs`:
- Around line 80-85: The enrichment step currently copies provider strings
verbatim, so a registered provider like Some("") remains an empty-string instead
of None; update the logic in decide.rs (the block that sets decision.provider
from models) to trim the provider string and convert blank/whitespace-only
values to None (e.g., when calling .and_then(|mr| mr.provider.clone()), map the
string via .map(|p| { let t=p.trim(); if t.is_empty() { None } else {
Some(t.to_string()) } }) so decision.provider becomes None for blank providers).
In `@llm-router/src/types.rs`:
- Around line 83-84: The doc comment for the `provider` field incorrectly
references `router::decide`; update it to state that `provider` is populated in
`llm-router/src/functions/decide.rs` (i.e., by the `decide(...)` function in
`functions::decide`) after `decide(...)` returns so the comment accurately
reflects where `provider` is set.
---
Nitpick comments:
In `@llm-router/src/router.rs`:
- Around line 628-662: Add a behavioral test that exercises the enrichment path
in llm-router/src/functions/decide.rs (the function that produces a
RoutingDecision for a selected model) to assert that when a ModelRegistration
has provider = Some("...") the returned RoutingDecision copies that provider;
create a ModelRegistration with provider set, invoke the decision function (the
one in decide.rs that selects/returns a RoutingDecision), and assert the
resulting RoutingDecision.provider == Some("your-provider") to ensure provider
enrichment from ModelRegistration works (use the existing RoutingDecision and
ModelRegistration types to locate fields).
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: f4618b48-ce4a-4734-8b01-afb594dc4f57
📒 Files selected for processing (3)
llm-router/src/functions/decide.rsllm-router/src/router.rsllm-router/src/types.rs
| if !decision.model.is_empty() && decision.provider.is_none() { | ||
| decision.provider = models | ||
| .iter() | ||
| .find(|mr| mr.model == decision.model) | ||
| .and_then(|mr| mr.provider.clone()); | ||
| } |
There was a problem hiding this comment.
Normalize blank provider values to None during enrichment.
At Line 80-85, a registered provider: Some("") will propagate as an empty string. Prefer treating blank/whitespace provider values as unset.
Proposed fix
if !decision.model.is_empty() && decision.provider.is_none() {
decision.provider = models
.iter()
.find(|mr| mr.model == decision.model)
- .and_then(|mr| mr.provider.clone());
+ .and_then(|mr| {
+ mr.provider
+ .as_deref()
+ .map(str::trim)
+ .filter(|p| !p.is_empty())
+ .map(str::to_owned)
+ });
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| if !decision.model.is_empty() && decision.provider.is_none() { | |
| decision.provider = models | |
| .iter() | |
| .find(|mr| mr.model == decision.model) | |
| .and_then(|mr| mr.provider.clone()); | |
| } | |
| if !decision.model.is_empty() && decision.provider.is_none() { | |
| decision.provider = models | |
| .iter() | |
| .find(|mr| mr.model == decision.model) | |
| .and_then(|mr| { | |
| mr.provider | |
| .as_deref() | |
| .map(str::trim) | |
| .filter(|p| !p.is_empty()) | |
| .map(str::to_owned) | |
| }); | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@llm-router/src/functions/decide.rs` around lines 80 - 85, The enrichment step
currently copies provider strings verbatim, so a registered provider like
Some("") remains an empty-string instead of None; update the logic in decide.rs
(the block that sets decision.provider from models) to trim the provider string
and convert blank/whitespace-only values to None (e.g., when calling
.and_then(|mr| mr.provider.clone()), map the string via .map(|p| { let
t=p.trim(); if t.is_empty() { None } else { Some(t.to_string()) } }) so
decision.provider becomes None for blank providers).
| /// Populated by `router::decide` after the routing decision is made. | ||
| /// Optional because models can be referenced before they're registered |
There was a problem hiding this comment.
Fix stale doc reference for provider population path.
At Line 83, the comment says router::decide populates provider, but it is actually set in llm-router/src/functions/decide.rs after decide(...) returns. This is misleading.
Proposed doc fix
- /// Populated by `router::decide` after the routing decision is made.
+ /// Populated by `functions::decide::handle` after `router::decide` returns.📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| /// Populated by `router::decide` after the routing decision is made. | |
| /// Optional because models can be referenced before they're registered | |
| /// Populated by `functions::decide::handle` after `router::decide` returns. | |
| /// Optional because models can be referenced before they're registered |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@llm-router/src/types.rs` around lines 83 - 84, The doc comment for the
`provider` field incorrectly references `router::decide`; update it to state
that `provider` is populated in `llm-router/src/functions/decide.rs` (i.e., by
the `decide(...)` function in `functions::decide`) after `decide(...)` returns
so the comment accurately reflects where `provider` is set.
…d TUI snapshots, upstream llm-router merged
Three concerns addressed in one cut.
1. Models-catalog: state-first registry (workers/models-catalog/src/lib.rs)
The compile-time `data/models.json` violated the "narrow worker, no
hardcoded catalog, state-register only" rule from the project
feedback notes. Catalog is now state-first:
- `models::register` — new iii fn that writes a Model to state under
`models:<provider>:<id>` in scope `models`. Any caller (router,
registry-sync worker, user CLI) can extend the catalog at runtime
without touching this crate.
- `models::list/get/supports` — read from state via `state::list` /
`state::get`, fall back to the embedded baseline only when state
is empty. Once state has at least one entry, the embedded copy is
never consulted.
- On `register_with_iii`, `seed_state_if_empty` bulk-loads the
embedded baseline into state if no `models:` keys exist. Existing
setups keep zero-config; new deployments can clear state and
register their own catalog from scratch.
Sync `pub fn list/get/supports` keep the embedded baseline as a
best-effort fallback for callers that can't await a bus round-trip
(context-compaction's `CompactionConfig::new` is the only consumer
in-tree). Documented as best-effort.
2. TUI snapshot coverage 2 → 6
New snapshots cover loop states the existing `idle` + `after-msg`
pair didn't:
- `running_with_tool_call_80x22`: TurnStart + ToolExecutionStart
while in Running status (spinner + queue glyphs).
- `tool_execution_end_80x22`: ToolExecutionEnd with a host bash
result (collapsed tool block, exit code).
- `assistant_error_80x20`: Assistant message with StopReason::Error
and an `auth failed` error_message.
- `wide_120x30`: 120-column layout proving the renderer scales
beyond the 80-column default.
Each snapshot diffs a trimmed cell buffer (style discarded for
stability) so colour-scheme changes don't false-positive.
3. llm-router upstream PR merged
iii-hq/workers#57 merged 2026-04-29 (commit
1117e2eceeb0d8feb9c8157f82b05bf722345d2c). The `provider` field on
`RoutingDecision` is now upstream. Updated:
- README composability ladder Tier 3 — drops "extension" framing
- harness-runtime register.rs — comment cites merge commit + date
- README scoreboard — adds "llm-router provider field upstream ✅"
4. New e2e: models_catalog_state_register_round_trip
Drives `models::register` with a custom Model + custom provider id,
then asserts `models::get` and `models::supports` return the
registered values — proving state, not the embedded baseline, is
the source of truth.
5. Workspace bump 0.11.7 → 0.11.8.
Verified live (ws://127.0.0.1:49134, post-engine-restart):
- replay-test e2e: 10/10 ✅
- workspace lib tests: 41/41 ✅
- TUI render snapshots: 6/6 ✅
- clippy: clean
- fmt: clean
Note for users running iii alongside heavy bus consumers (iiiterm,
agentmemory): the engine can wedge after long sessions — `harnessd
status` returns "engine unreachable" while the port still listens.
Restart with `pkill -f 'iii.*default-config' && iii --use-default-config`.
Summary
Add
provider: Option<String>toRoutingDecisionand populate it from the chosen model'sModelRegistration.providerafter the routing decision is made.Why
Agent harnesses that dispatch by
provider::<name>::stream_assistantneed the provider name to route the call. Today they either:state.list("models:")afterrouter::decidereturnsanthropic/claude-...) themselvesThe router already loads the full models list inside
decide, so wiring the provider through is a free two-line enrichment instead of N+1 round-trips on every routed call.Concrete consumer:
iii-experimental/harness::agent::stream_assistantcallsrouter::decideonce on the bus and then dispatches toprovider::<name>::stream_assistant— without this field, every routed turn pays a second state lookup.Changes
types.rs — new field on
RoutingDecision:Optional + skipped-when-none keeps the wire shape backward compatible for existing consumers.
functions/decide.rs — enrichment after
router::decide:Only fills when a registration exists. Models referenced before registration (e.g. policy
model: "auto"resolved by classifier to an unregistered model) getNone, which consumers can fall back on by parsing namespaced model ids.router.rs — 7
RoutingDecisionconstruction sites updated withprovider: None. Two new tests cover serde:test_routing_decision_serializes_provider_when_settest_routing_decision_omits_provider_when_unsetBackward compatibility
Option<String>with#[serde(default)]— old clients deserializing the new shape seeNoneskip_serializing_if = "Option::is_none"— old shape on the wire when no provider is setRoutingRequest, function ids, or state schemaTest plan
cargo test— 32/32 pass (30 existing + 2 new)cargo clippy --all-targets -- -D warnings— cleancargo fmt --check— cleanprovidershould also be set by the AB-test variant path (current diff only relies on themodels:lookup, which covers all paths)Summary by CodeRabbit