Skip to content

feat(llm-router): expose provider in RoutingDecision#57

Merged
rohitg00 merged 1 commit intomainfrom
harness/expose-provider-in-routing-decision
Apr 29, 2026
Merged

feat(llm-router): expose provider in RoutingDecision#57
rohitg00 merged 1 commit intomainfrom
harness/expose-provider-in-routing-decision

Conversation

@rohitg00
Copy link
Copy Markdown
Contributor

@rohitg00 rohitg00 commented Apr 28, 2026

Summary

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 that dispatch by provider::<name>::stream_assistant need the provider name to route the call. Today they either:

  • Do a second state.list("models:") after router::decide returns
  • Or parse a namespaced model id (anthropic/claude-...) themselves

The 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_assistant calls router::decide once on the bus and then dispatches to provider::<name>::stream_assistant — without this field, every routed turn pays a second state lookup.

Changes

  • types.rs — new field on RoutingDecision:

    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub provider: Option<String>,

    Optional + skipped-when-none keeps the wire shape backward compatible for existing consumers.

  • functions/decide.rs — enrichment after router::decide:

    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());
    }

    Only fills when a registration exists. Models referenced before registration (e.g. policy model: "auto" resolved by classifier to an unregistered model) get None, which consumers can fall back on by parsing namespaced model ids.

  • router.rs — 7 RoutingDecision construction sites updated with provider: None. Two new tests cover serde:

    • test_routing_decision_serializes_provider_when_set
    • test_routing_decision_omits_provider_when_unset

Backward compatibility

  • Field is Option<String> with #[serde(default)] — old clients deserializing the new shape see None
  • skip_serializing_if = "Option::is_none" — old shape on the wire when no provider is set
  • No changes to RoutingRequest, function ids, or state schema

Test plan

  • cargo test — 32/32 pass (30 existing + 2 new)
  • cargo clippy --all-targets -- -D warnings — clean
  • cargo fmt --check — clean
  • Reviewer sanity check on whether provider should also be set by the AB-test variant path (current diff only relies on the models: lookup, which covers all paths)

Summary by CodeRabbit

  • New Features
    • Routing decisions now include optional provider information. When a model is selected and available in the system, the decision output includes the associated provider identifier. The provider field only appears when available, maintaining backward compatibility.

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.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 28, 2026

📝 Walkthrough

Walkthrough

The pull request adds provider information enrichment to routing decisions. A new optional provider field is added to the RoutingDecision type, initialized as None during routing, and post-processed in the decide handler by looking up the selected model in the models registry to extract and assign its provider.

Changes

Cohort / File(s) Summary
Type Definition
llm-router/src/types.rs
Added optional provider: Option<String> field to RoutingDecision with conditional serialization (only present when Some).
Core Routing Logic
llm-router/src/router.rs
Initialized new provider field to None in all routing decision paths and added two unit tests verifying JSON serialization behavior with and without provider values.
Provider Enrichment
llm-router/src/functions/decide.rs
Post-processes routing decisions to populate provider by searching loaded model registrations for a matching model and assigning the provider from that registration when available.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~12 minutes

Poem

🐰 A rabbit hops through routing trees,
Finding which model fits with ease,
Then looks up what provider's best,
And fills the field with joyful zest!
Provider knowledge, now in flight!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 60.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately and concisely summarizes the main change: adding a provider field to RoutingDecision for exposure in routing decisions.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch harness/expose-provider-in-routing-decision

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

rohitg00 added a commit to iii-experimental/harness that referenced this pull request Apr 28, 2026
…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.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 from ModelRegistration for 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

📥 Commits

Reviewing files that changed from the base of the PR and between b662919 and 699fbed.

📒 Files selected for processing (3)
  • llm-router/src/functions/decide.rs
  • llm-router/src/router.rs
  • llm-router/src/types.rs

Comment on lines +80 to +85
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());
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

Suggested change
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).

Comment thread llm-router/src/types.rs
Comment on lines +83 to +84
/// Populated by `router::decide` after the routing decision is made.
/// Optional because models can be referenced before they're registered
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

Suggested change
/// 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.

@rohitg00 rohitg00 merged commit 1117e2e into main Apr 29, 2026
6 of 7 checks passed
@rohitg00 rohitg00 deleted the harness/expose-provider-in-routing-decision branch April 29, 2026 09:37
rohitg00 added a commit to iii-experimental/harness that referenced this pull request Apr 29, 2026
…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`.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant