From 9d6026f714b77edb4fa6df28e6b45eef7602488a Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 11 Jul 2026 03:50:13 +0000 Subject: [PATCH] registry: high-level ModelRouter (workload-tier routing layer) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `registry::router` — the declarative workload-tier layer over the named model registry, and the crate-owned home for the projection hosts have re-implemented by hand (OpenHuman's RouterProvider + routes.rs): map named tiers (chat-v1, reasoning-v1, vision-v1, ...) onto concrete registered models with per-tier capability gates and ordered same-family fallback chains. - `WorkloadRoute` — one tier: alias -> model, optional CapabilitySet gate, ordered sibling fallback aliases. Pure metadata (names a model, owns none). - `ModelRouter` — insertion-ordered route table + optional default. Answers the three questions turn assembly needs: target_model(alias), fallback_policy(alias) ([alias, fallbacks...] leading with the primary so FallbackPolicy::next_after yields the alternate), required_capabilities(alias). Infallible last-write-wins builder (with_route/with_default) + duplicate-rejecting register(). Holds no models and drives no I/O — cheap cloneable policy read while wiring a registry + RunPolicy, so *what routes where* stays decoupled from *how a model is built*. Fills the long-declared ComponentKind::Router. Foundation for issue #4249 Phase 3 (RouterProvider -> crate ModelRegistry route-projection). Exported from registry::{ModelRouter, WorkloadRoute} + crate root. Module README under src/registry/router/. 9 unit tests + 1 doctest; fmt + clippy clean. Claude-Session: https://claude.ai/code/session_01U8NDGbt9tKj443VquzycLB --- src/lib.rs | 3 +- src/registry/mod.rs | 7 ++ src/registry/router/README.md | 48 ++++++++ src/registry/router/mod.rs | 208 ++++++++++++++++++++++++++++++++++ src/registry/router/test.rs | 127 +++++++++++++++++++++ src/registry/router/types.rs | 87 ++++++++++++++ 6 files changed, 479 insertions(+), 1 deletion(-) create mode 100644 src/registry/router/README.md create mode 100644 src/registry/router/mod.rs create mode 100644 src/registry/router/test.rs create mode 100644 src/registry/router/types.rs diff --git a/src/lib.rs b/src/lib.rs index 3299563..e5b68c8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -87,7 +87,8 @@ pub use error::{Result, TinyAgentsError}; pub use registry::{ AliasBinding, CapabilityRegistry, ComponentId, ComponentKind, ComponentMetadata, DiagnosticSeverity, ModelCapabilities, ModelCatalog, ModelCatalogEntry, ModelCatalogSnapshot, - ModelCatalogSource, ModelPricing, RegistryDiagnostic, RegistrySnapshot, + ModelCatalogSource, ModelPricing, ModelRouter, RegistryDiagnostic, RegistrySnapshot, + WorkloadRoute, }; // --- Language: registry → blueprint binding façade --- diff --git a/src/registry/mod.rs b/src/registry/mod.rs index 631108e..01549f8 100644 --- a/src/registry/mod.rs +++ b/src/registry/mod.rs @@ -20,11 +20,17 @@ //! - [`ModelCatalog`] ([`catalog`]) — a checked-in snapshot of provider model //! prices, context windows, and capabilities for deterministic, offline //! lookup (cost estimation, model selection, capability gating). +//! - [`ModelRouter`] ([`router`]) — the declarative workload-tier layer over the +//! named model registry: maps host workload aliases (`chat-v1`, `vision-v1`, …) +//! onto concrete registered models with per-tier capability gates and ordered +//! same-family fallback chains (registry component kind +//! [`Router`](ComponentKind::Router)). pub mod capability; pub mod catalog; pub mod component; pub mod diagnostics; +pub mod router; pub use capability::CapabilityRegistry; pub use catalog::{ @@ -33,3 +39,4 @@ pub use catalog::{ }; pub use component::{ComponentId, ComponentKind, ComponentMetadata}; pub use diagnostics::{AliasBinding, DiagnosticSeverity, RegistryDiagnostic, RegistrySnapshot}; +pub use router::{ModelRouter, WorkloadRoute}; diff --git a/src/registry/router/README.md b/src/registry/router/README.md new file mode 100644 index 0000000..386f79b --- /dev/null +++ b/src/registry/router/README.md @@ -0,0 +1,48 @@ +# `registry::router` — high-level model router + +The declarative **workload-tier** layer over the named model registry. Where +[`CapabilityRegistry`] resolves a model *by name* and the agent loop fails over +across a [`FallbackPolicy`], `ModelRouter` owns the *policy* that a host's tiers +(`chat-v1`, `reasoning-v1`, `vision-v1`, …) resolve to concrete models, with the +per-tier capability gates and same-family fallback ordering that go with them. + +## Public surface + +- **`WorkloadRoute`** — one named tier: `alias` → `model`, an optional + [`CapabilitySet`] gate, and an ordered list of same-family fallback aliases. + Pure metadata; names a model rather than owning one. +- **`ModelRouter`** — an insertion-ordered table of routes plus an optional + default alias. Answers the three questions turn assembly needs: + - `target_model(alias)` — which registered model does this alias forward to? + - `fallback_policy(alias)` — the `[alias, fallbacks…]` [`FallbackPolicy`] for a + turn whose primary is `alias` (`None` when there are no alternates). + - `required_capabilities(alias)` — the [`CapabilitySet`] to stamp on requests + routed here (`None` for an ungated text tier). + +Build with the infallible last-write-wins builder (`with_route`/`with_default`) +or the duplicate-rejecting `register`. + +## Design constraints + +- **Holds no models, drives no I/O.** It is cheap, cloneable policy read while + wiring a registry + `RunPolicy`. *What routes where* stays decoupled from *how + a model is built* (host provider/factory territory). +- **Insertion order is preserved** so `routes()` / `aliases()` iterate + deterministically — callers projecting the table onto a registry rely on it. +- **Fallback chains lead with the primary** because the crate's + [`FallbackPolicy::next_after`] expects the current name first and yields each + subsequent alternate. + +## Why (Phase 3 / issue #4249) + +This is the crate-owned home for the projection hosts have re-implemented by +hand (OpenHuman's `RouterProvider` + `routes.rs`): register a model per tier +alias, build a fallback policy, stamp a per-turn capability requirement. Moving +the *declaration* of the tier table into the crate lets a host describe its +tiered routing once and hand it over, instead of open-coding alias resolution, +fallback ordering, and capability gating at the turn-assembly boundary. + +[`CapabilityRegistry`]: ../capability/index.html +[`CapabilitySet`]: ../../harness/model/struct.CapabilitySet.html +[`FallbackPolicy`]: ../../harness/retry/struct.FallbackPolicy.html +[`FallbackPolicy::next_after`]: ../../harness/retry/struct.FallbackPolicy.html#method.next_after diff --git a/src/registry/router/mod.rs b/src/registry/router/mod.rs new file mode 100644 index 0000000..dc2a82d --- /dev/null +++ b/src/registry/router/mod.rs @@ -0,0 +1,208 @@ +//! High-level **model router** — the declarative workload-tier layer over the +//! named model registry (registry component kind +//! [`Router`](crate::registry::ComponentKind::Router)). +//! +//! # Why this exists +//! +//! [`CapabilityRegistry`](crate::registry::CapabilityRegistry) and the harness +//! [`ModelRegistry`](crate::harness::runtime::ModelRegistry) resolve a model *by +//! name* and the agent loop can fail over across a +//! [`FallbackPolicy`](crate::harness::retry::FallbackPolicy) — but neither owns +//! the *policy* that maps a host's **workload tiers** (`chat-v1`, `reasoning-v1`, +//! `vision-v1`, …) onto concrete models, nor the per-tier capability gates and +//! same-family fallback ordering that go with them. Hosts have historically +//! re-implemented that projection by hand (OpenHuman's `RouterProvider` + +//! `routes.rs`): register a model per tier alias, build a `FallbackPolicy`, stamp +//! a required [`CapabilitySet`](crate::harness::model::CapabilitySet) per turn. +//! +//! [`ModelRouter`] is the crate-owned home for exactly that policy. A host +//! *declares* its tier table once — each [`WorkloadRoute`] names the model an +//! alias forwards to, the capabilities a request routed there must satisfy, and +//! the ordered sibling aliases to fall back to — and the router answers the three +//! questions the turn assembly needs: +//! +//! - **resolution**: which registered model does this alias forward to? +//! ([`target_model`](ModelRouter::target_model)) +//! - **fallback**: the [`FallbackPolicy`] for a turn whose primary is this alias +//! (`[alias, fallbacks…]`) ([`fallback_policy`](ModelRouter::fallback_policy)) +//! - **capability gate**: the [`CapabilitySet`] to stamp on requests routed here +//! ([`required_capabilities`](ModelRouter::required_capabilities)) +//! +//! It holds no models and drives no I/O — it is pure, cheap, cloneable policy +//! that a harness assembler reads while wiring a registry + run policy. That +//! keeps the *what routes where* declarative and testable in isolation from the +//! *how a model is built* (which stays with the host provider/factory). +//! +//! # Example +//! +//! ``` +//! use tinyagents::harness::model::CapabilitySet; +//! use tinyagents::registry::router::{ModelRouter, WorkloadRoute}; +//! +//! let router = ModelRouter::new() +//! .with_route(WorkloadRoute::new("chat-v1", "chat-v1").with_fallbacks(["burst-v1"])) +//! .with_route(WorkloadRoute::new("burst-v1", "burst-v1").with_fallbacks(["chat-v1"])) +//! .with_route( +//! WorkloadRoute::new("vision-v1", "vision-v1") +//! .requiring(CapabilitySet { image_in: true, ..CapabilitySet::default() }), +//! ) +//! .with_default("chat-v1"); +//! +//! // A chat turn fails over to its sibling burst tier. +//! let policy = router.fallback_policy("chat-v1").unwrap(); +//! assert_eq!(policy.next_after("chat-v1"), Some("burst-v1")); +//! +//! // A vision turn is capability-gated and primary-only (no fallback). +//! assert!(router.required_capabilities("vision-v1").unwrap().image_in); +//! assert!(router.fallback_policy("vision-v1").is_none()); +//! ``` + +mod types; + +#[cfg(test)] +mod test; + +pub use types::WorkloadRoute; + +use crate::error::{Result, TinyAgentsError}; +use crate::harness::model::CapabilitySet; +use crate::harness::retry::FallbackPolicy; + +/// A declarative, name-addressable router over registered models: it maps named +/// workload tiers to concrete registered model names and owns the per-tier +/// capability gates and same-family fallback ordering. +/// +/// Insertion order is preserved so [`routes`](Self::routes) and +/// [`aliases`](Self::aliases) iterate deterministically (registration order), +/// which callers rely on when projecting the table onto a registry. +/// +/// See the [module docs](self) for the design rationale and an example. +#[derive(Clone, Debug, Default)] +pub struct ModelRouter { + /// Insertion-ordered route table. Small (a handful of tiers), so linear scans + /// are cheaper than a map and keep ordering deterministic. + routes: Vec, + /// The alias dispatched to when a caller names no tier, if set. + default_alias: Option, +} + +impl ModelRouter { + /// An empty router with no routes and no default. + pub fn new() -> Self { + Self::default() + } + + /// Adds a route, returning `self` for chaining (builder style). + /// + /// A later route with the same alias **overwrites** the earlier one (last + /// write wins), keeping builder chains ergonomic. Use + /// [`register`](Self::register) for the fallible, duplicate-rejecting form. + #[must_use] + pub fn with_route(mut self, route: WorkloadRoute) -> Self { + self.upsert(route); + self + } + + /// Sets the default alias (dispatched to when a caller names no tier), + /// returning `self` for chaining. + #[must_use] + pub fn with_default(mut self, alias: impl Into) -> Self { + self.default_alias = Some(alias.into()); + self + } + + /// Registers a route, rejecting a duplicate alias. + /// + /// # Errors + /// + /// Returns [`TinyAgentsError::DuplicateComponent`] if a route with the same + /// alias is already registered. Use [`with_route`](Self::with_route) for the + /// infallible last-write-wins builder form. + pub fn register(&mut self, route: WorkloadRoute) -> Result<&mut Self> { + if self.routes.iter().any(|r| r.alias == route.alias) { + return Err(TinyAgentsError::DuplicateComponent(format!( + "router route '{}'", + route.alias + ))); + } + self.routes.push(route); + Ok(self) + } + + /// Sets the default alias (dispatched to when a caller names no tier). + pub fn set_default(&mut self, alias: impl Into) -> &mut Self { + self.default_alias = Some(alias.into()); + self + } + + /// The default alias, if one is set. + pub fn default_alias(&self) -> Option<&str> { + self.default_alias.as_deref() + } + + /// The route registered under `alias`, if any. + pub fn route(&self, alias: &str) -> Option<&WorkloadRoute> { + self.routes.iter().find(|r| r.alias == alias) + } + + /// All routes in registration order. + pub fn routes(&self) -> &[WorkloadRoute] { + &self.routes + } + + /// Every registered alias in registration order. + pub fn aliases(&self) -> impl Iterator { + self.routes.iter().map(|r| r.alias.as_str()) + } + + /// Whether any route is registered. + pub fn is_empty(&self) -> bool { + self.routes.is_empty() + } + + /// The concrete registered model name `alias` forwards to, if `alias` is a + /// known route. + pub fn target_model(&self, alias: &str) -> Option<&str> { + self.route(alias).map(|r| r.model.as_str()) + } + + /// The [`FallbackPolicy`] for a turn whose primary/effective tier is `alias`: + /// the chain `[alias, fallbacks…]`. + /// + /// The crate's [`FallbackPolicy::next_after`] traversal expects the current + /// (primary) name as the first entry and yields each subsequent alternate, so + /// the primary alias itself heads the returned chain. + /// + /// Returns `None` when `alias` is unknown or has no fallbacks (e.g. a + /// capability-gated primary-only tier), so no fallback policy is installed for + /// that turn. + pub fn fallback_policy(&self, alias: &str) -> Option { + let route = self.route(alias)?; + if route.fallbacks.is_empty() { + return None; + } + let mut chain = Vec::with_capacity(route.fallbacks.len() + 1); + chain.push(route.alias.clone()); + chain.extend(route.fallbacks.iter().cloned()); + Some(FallbackPolicy::new(chain)) + } + + /// The [`CapabilitySet`] to stamp on requests routed to `alias`, or `None` + /// when the route imposes no requirement (the common text turn) — so the + /// caller installs no capability gate. + pub fn required_capabilities(&self, alias: &str) -> Option { + self.route(alias) + .filter(|r| r.has_capability_gate()) + .map(|r| r.requires.clone()) + } + + /// Insert-or-overwrite by alias (last write wins), preserving the original + /// position on overwrite so builder order stays stable. + fn upsert(&mut self, route: WorkloadRoute) { + if let Some(slot) = self.routes.iter_mut().find(|r| r.alias == route.alias) { + *slot = route; + } else { + self.routes.push(route); + } + } +} diff --git a/src/registry/router/test.rs b/src/registry/router/test.rs new file mode 100644 index 0000000..8c7cdd2 --- /dev/null +++ b/src/registry/router/test.rs @@ -0,0 +1,127 @@ +//! Unit tests for [`ModelRouter`](super::ModelRouter) / [`WorkloadRoute`]. + +use super::{ModelRouter, WorkloadRoute}; +use crate::harness::model::CapabilitySet; + +/// Mirrors OpenHuman's workload tiers so the tests exercise the exact projection +/// the host router needs: fast/chat siblings, heavy reasoning siblings, and a +/// capability-gated vision primary-only tier. +fn oh_like_router() -> ModelRouter { + ModelRouter::new() + .with_route(WorkloadRoute::new("chat-v1", "chat-v1").with_fallbacks(["burst-v1"])) + .with_route(WorkloadRoute::new("burst-v1", "burst-v1").with_fallbacks(["chat-v1"])) + .with_route( + WorkloadRoute::new("reasoning-v1", "reasoning-v1").with_fallbacks(["agentic-v1"]), + ) + .with_route(WorkloadRoute::new("agentic-v1", "agentic-v1").with_fallbacks(["reasoning-v1"])) + .with_route( + WorkloadRoute::new("vision-v1", "vision-v1").requiring(CapabilitySet { + image_in: true, + ..CapabilitySet::default() + }), + ) + .with_default("chat-v1") +} + +#[test] +fn resolves_alias_to_target_model() { + let r = oh_like_router(); + assert_eq!(r.target_model("reasoning-v1"), Some("reasoning-v1")); + assert_eq!(r.target_model("nope"), None); + assert_eq!(r.default_alias(), Some("chat-v1")); +} + +#[test] +fn fallback_policy_heads_with_primary_then_alternates() { + let r = oh_like_router(); + let policy = r.fallback_policy("chat-v1").expect("chat has a sibling"); + // The chain leads with the primary so `next_after(primary)` yields the alternate. + assert_eq!( + policy.models, + vec!["chat-v1".to_string(), "burst-v1".to_string()] + ); + assert_eq!(policy.next_after("chat-v1"), Some("burst-v1")); + assert_eq!(policy.next_after("burst-v1"), None); +} + +#[test] +fn fallback_policy_is_none_without_alternates() { + let r = oh_like_router(); + // vision is primary-only (a text fallback can't satisfy image_in). + assert!(r.fallback_policy("vision-v1").is_none()); + // Unknown alias installs no policy. + assert!(r.fallback_policy("ghost").is_none()); +} + +#[test] +fn capability_gate_only_for_gated_routes() { + let r = oh_like_router(); + let gate = r + .required_capabilities("vision-v1") + .expect("vision requires image_in"); + assert!(gate.image_in); + // A plain text tier imposes no gate, so the common turn is unaffected. + assert!(r.required_capabilities("chat-v1").is_none()); + assert!(r.required_capabilities("unknown").is_none()); +} + +#[test] +fn aliases_preserve_registration_order() { + let r = oh_like_router(); + let aliases: Vec<&str> = r.aliases().collect(); + assert_eq!( + aliases, + vec![ + "chat-v1", + "burst-v1", + "reasoning-v1", + "agentic-v1", + "vision-v1" + ] + ); +} + +#[test] +fn register_rejects_duplicate_alias() { + let mut r = ModelRouter::new(); + r.register(WorkloadRoute::new("chat-v1", "chat-v1")) + .unwrap(); + let err = r + .register(WorkloadRoute::new("chat-v1", "other")) + .unwrap_err(); + assert!(err.to_string().contains("chat-v1"), "err: {err}"); + // The original mapping is intact. + assert_eq!(r.target_model("chat-v1"), Some("chat-v1")); +} + +#[test] +fn with_route_last_write_wins_and_keeps_position() { + let r = ModelRouter::new() + .with_route(WorkloadRoute::new("a", "a-model")) + .with_route(WorkloadRoute::new("b", "b-model")) + // Overwrite `a` in place — position preserved, target updated. + .with_route(WorkloadRoute::new("a", "a-model-v2")); + assert_eq!(r.target_model("a"), Some("a-model-v2")); + assert_eq!(r.aliases().collect::>(), vec!["a", "b"]); + assert_eq!(r.routes().len(), 2); +} + +#[test] +fn empty_router_answers_nothing() { + let r = ModelRouter::new(); + assert!(r.is_empty()); + assert!(r.default_alias().is_none()); + assert!(r.target_model("x").is_none()); + assert!(r.fallback_policy("x").is_none()); + assert!(r.required_capabilities("x").is_none()); +} + +#[test] +fn workload_route_serde_skips_defaults() { + // A bare route round-trips compactly (no `requires`/`fallbacks` noise). + let route = WorkloadRoute::new("chat-v1", "chat-v1"); + let json = serde_json::to_string(&route).unwrap(); + assert_eq!(json, r#"{"alias":"chat-v1","model":"chat-v1"}"#); + let back: WorkloadRoute = serde_json::from_str(&json).unwrap(); + assert_eq!(back, route); +} diff --git a/src/registry/router/types.rs b/src/registry/router/types.rs new file mode 100644 index 0000000..8de458d --- /dev/null +++ b/src/registry/router/types.rs @@ -0,0 +1,87 @@ +//! Declarative types for the high-level [`ModelRouter`](super::ModelRouter): a +//! [`WorkloadRoute`] (one named tier) and the router that owns an ordered set of +//! them. + +use serde::{Deserialize, Serialize}; + +use crate::harness::model::CapabilitySet; + +/// One declarative **workload route**: a stable alias (e.g. `reasoning-v1`) that +/// resolves to a concrete registered model, plus the capability gate a request +/// routed here must satisfy and an ordered same-family fallback chain of sibling +/// aliases. +/// +/// A route is pure metadata — it names a model rather than owning one, so the +/// same route table can be declared once and projected onto any +/// [`CapabilityRegistry`](crate::registry::CapabilityRegistry) / +/// [`ModelRegistry`](crate::harness::runtime::ModelRegistry) that has registered +/// those model names. This is what lets a host describe its tiered routing +/// (workload aliases → concrete BYOK/managed/local models) declaratively and hand +/// it to the crate, instead of re-implementing alias resolution + fallback +/// ordering + capability gating by hand. +/// +/// # Fields +/// - `alias` — the routable name callers dispatch to (the registry model name the +/// route is registered under). +/// - `model` — the concrete provider model id this alias forwards to. Often equal +/// to `alias` when the backend resolves the alias server-side (a managed tier), +/// or a real model id when the router itself is the resolver (BYOK). +/// - `requires` — the [`CapabilitySet`] stamped on every request routed to this +/// alias, so an unfit candidate is rejected/skipped pre-dispatch. [`Default`] +/// (require nothing) is the common case. +/// - `fallbacks` — ordered sibling aliases to try when this route errors. Each +/// entry should itself be a registered route so the crate can resolve it. Kept +/// short (0–2 same-family alternates) to bound cross-route fan-out. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkloadRoute { + /// The routable alias (registry model name) callers dispatch to. + pub alias: String, + /// The concrete provider model id this alias forwards to. + pub model: String, + /// Capabilities a request routed to this alias must satisfy. + #[serde(default, skip_serializing_if = "is_default_capabilities")] + pub requires: CapabilitySet, + /// Ordered sibling aliases to fall back to when this route errors. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub fallbacks: Vec, +} + +impl WorkloadRoute { + /// A route mapping `alias` → `model` with no capability gate and no fallbacks. + pub fn new(alias: impl Into, model: impl Into) -> Self { + Self { + alias: alias.into(), + model: model.into(), + requires: CapabilitySet::default(), + fallbacks: Vec::new(), + } + } + + /// Sets the capability gate for requests routed to this alias. + #[must_use] + pub fn requiring(mut self, requires: CapabilitySet) -> Self { + self.requires = requires; + self + } + + /// Sets the ordered same-family fallback aliases for this route. + #[must_use] + pub fn with_fallbacks( + mut self, + fallbacks: impl IntoIterator>, + ) -> Self { + self.fallbacks = fallbacks.into_iter().map(Into::into).collect(); + self + } + + /// Whether this route imposes any capability requirement. + pub(super) fn has_capability_gate(&self) -> bool { + self.requires != CapabilitySet::default() + } +} + +/// Skip serializing a `requires` field that requires nothing (the common case), +/// keeping declared route tables compact. +fn is_default_capabilities(set: &CapabilitySet) -> bool { + *set == CapabilitySet::default() +}