zeph_llm/router/mod.rs
1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Multi-provider router with pluggable routing strategies.
5//!
6//! [`RouterProvider`] implements [`LlmProvider`](crate::provider::LlmProvider) and forwards
7//! every call to one of its configured backends, chosen according to the active
8//! [`RouterStrategy`].
9//!
10//! # Routing strategies
11//!
12//! | Strategy | Module | Description |
13//! |---|---|---|
14//! | [`RouterStrategy::Ema`] | `crate::ema` | EMA-weighted latency-aware ordering |
15//! | [`RouterStrategy::Thompson`] | [`thompson`] | Bayesian Beta-distribution sampling |
16//! | [`RouterStrategy::Cascade`] | [`cascade`] | Cheapest-first with quality escalation |
17//! | [`RouterStrategy::Bandit`] | [`bandit`] | Contextual `LinUCB` (PILOT algorithm) |
18//!
19//! Strategies are selected via builder methods on [`RouterProvider`]:
20//! - [`RouterProvider::with_ema`]
21//! - [`RouterProvider::with_thompson`]
22//! - [`RouterProvider::with_cascade`]
23//! - [`RouterProvider::with_bandit`]
24//!
25//! # Reputation-Aware Provider Selection (RAPS)
26//!
27//! All strategies support an optional Bayesian reputation layer ([`reputation`]) that
28//! penalizes providers which produce semantically invalid tool arguments. Enable with
29//! [`RouterProvider::with_reputation`].
30//!
31//! # Agent Stability Index (ASI)
32//!
33//! An optional session-level coherence tracker ([`asi`]) measures embedding-based
34//! response quality and feeds back into Thompson selection. Enable with
35//! [`RouterProvider::with_asi`].
36//!
37//! # Security
38//!
39//! Thompson and Bandit state files are loaded from user-controlled paths at startup.
40//! Files are validated (finite floats, clamped range) and written with `0o600` permissions
41//! on Unix. Do not store state files in world-writable directories.
42
43mod builder;
44mod chat;
45mod config;
46mod embed_cache;
47mod provider_impl;
48mod select;
49
50pub mod asi;
51pub mod aware;
52pub mod bandit;
53pub mod cascade;
54pub mod coe;
55pub mod reputation;
56pub mod state;
57pub mod thompson;
58pub mod triage;
59
60pub use aware::RouterAware;
61pub use config::{AsiRouterConfig, BanditRouterConfig, CascadeRouterConfig, RouterStrategy};
62pub use state::RouterState;
63
64pub(crate) use embed_cache::BanditEmbedCache;
65
66use std::sync::Arc;
67use std::sync::atomic::AtomicU64;
68
69use parking_lot::Mutex;
70
71use asi::AsiState;
72use bandit::BanditState;
73use cascade::CascadeState;
74use coe::CoeRouter;
75use reputation::ReputationTracker;
76use thompson::ThompsonState;
77
78use crate::ema::EmaTracker;
79use crate::provider::StatusTx;
80
81/// Rate-limits the ASI coherence WARN to at most once per 60 seconds process-wide.
82static ASI_WARN_LAST_SECS: AtomicU64 = AtomicU64::new(0);
83
84/// Maximum number of concurrent fire-and-forget ASI coherence update tasks.
85///
86/// When the `JoinSet` reaches this limit, new spawns are skipped (not aborted) to
87/// preserve in-flight work. ASI tasks are analytics-only and do not affect
88/// memory persistence.
89const MAX_ASI_TASKS: usize = 8;
90
91/// Runs `f` without blocking the Tokio executor.
92///
93/// On a multi-thread runtime uses `block_in_place`; on a `current_thread` runtime (unit
94/// tests, single-threaded entry points) falls back to a direct call since there is no
95/// executor thread pool to offload to.
96fn blocking_load<T>(f: impl FnOnce() -> T) -> T {
97 if tokio::runtime::Handle::try_current()
98 .is_ok_and(|h| h.runtime_flavor() == tokio::runtime::RuntimeFlavor::MultiThread)
99 {
100 tokio::task::block_in_place(f)
101 } else {
102 f()
103 }
104}
105
106/// Returns `true` when any message carries a `MessagePart::Image`.
107///
108/// Shared by [`triage::TriageRouter`] and [`RouterProvider`]'s vision-tier dispatch safety
109/// net (spec-072 C3).
110pub(crate) fn messages_contain_image(messages: &[crate::provider::Message]) -> bool {
111 messages.iter().any(|m| {
112 m.parts
113 .iter()
114 .any(|p| matches!(p, crate::provider::MessagePart::Image(_)))
115 })
116}
117
118/// Drop every `MessagePart::Image` from a message set — the text placeholder from the
119/// companion `MessagePart::ToolResult` (spec-072 §8 control 2) always remains as the
120/// fallback, so the request stays well-formed for a provider/tier that cannot see the image.
121///
122/// Delegates the per-message filter to [`crate::provider::MessagePart::strip_images`], the
123/// same helper `Agent::persist_message`/`TranscriptWriter::append` use (#6305/#6307).
124pub(crate) fn strip_image_parts(
125 messages: &[crate::provider::Message],
126) -> Vec<crate::provider::Message> {
127 messages
128 .iter()
129 .cloned()
130 .map(|mut m| {
131 m.parts = crate::provider::MessagePart::strip_images(&m.parts);
132 m
133 })
134 .collect()
135}
136
137/// Multi-provider LLM router implementing [`LlmProvider`](crate::provider::LlmProvider).
138///
139/// Construct with [`RouterProvider::new`] and configure a routing strategy via the
140/// builder methods. All configuration is immutable after construction except for
141/// runtime state (EMA statistics, Thompson distribution, bandit weights) which is
142/// stored behind `Arc<Mutex<_>>` and updated on every successful call.
143///
144/// Cloning is cheap: [`RouterState`] and all per-strategy state are `Arc`-wrapped
145/// and shared between the original and all clones — clone cost is proportional to
146/// the number of `Arc` fields, not to provider count or strategy complexity.
147#[derive(Debug, Clone)]
148pub struct RouterProvider {
149 /// Shared cross-strategy runtime signals (providers, turn counter, MAR, etc.).
150 ///
151 /// All fields inside are `Arc`-wrapped; clone is O(1).
152 pub(crate) state: RouterState,
153 status_tx: Option<StatusTx>,
154 ema: Option<EmaTracker>,
155 strategy: RouterStrategy,
156 thompson: Option<Arc<Mutex<ThompsonState>>>,
157 /// Path for persisting Thompson state. `None` disables persistence.
158 thompson_state_path: Option<std::path::PathBuf>,
159 /// Cascade routing state (quality history per provider).
160 cascade_state: Option<Arc<Mutex<CascadeState>>>,
161 /// Cascade routing configuration.
162 cascade_config: Option<CascadeRouterConfig>,
163 /// Bayesian reputation tracker (RAPS). None when disabled.
164 reputation: Option<Arc<Mutex<ReputationTracker>>>,
165 /// Path for persisting reputation state.
166 reputation_state_path: Option<std::path::PathBuf>,
167 /// Reputation weight in [0.0, 1.0] for routing score blend.
168 reputation_weight: f64,
169 /// PILOT bandit state.
170 bandit: Option<Arc<Mutex<BanditState>>>,
171 /// Path for persisting bandit state. `None` disables persistence.
172 bandit_state_path: Option<std::path::PathBuf>,
173 /// Bandit routing configuration.
174 bandit_config: Option<BanditRouterConfig>,
175 /// Dedicated embedding provider for bandit feature vectors.
176 /// When `None`, bandit falls back to Thompson/uniform on embed failure.
177 bandit_embedding_provider: Option<Arc<dyn crate::provider_dyn::LlmProviderDyn>>,
178 /// LRU embedding cache: maps query-string hash to feature vector.
179 /// Shared across requests; keyed by `u64` hash of query text.
180 bandit_embed_cache: Arc<Mutex<BanditEmbedCache>>,
181 /// Agent Stability Index state (session-only coherence tracking).
182 asi: Option<Arc<Mutex<AsiState>>>,
183 /// ASI configuration. `None` when ASI is disabled.
184 asi_config: Option<AsiRouterConfig>,
185 /// Embedding-based quality gate threshold. `None` = disabled.
186 /// After provider selection, `cosine_similarity(query_emb, response_emb)` must be >= this
187 /// value; otherwise the next provider in the ordered list is tried.
188 quality_gate: Option<f32>,
189 /// `CoE` (Collaborative Entropy) router. `None` when `CoE` is disabled.
190 coe: Option<Arc<CoeRouter>>,
191 /// Per-call timeout for `embed()` across all non-bandit providers (milliseconds).
192 /// Defaults to 5000 ms. A stalled provider is skipped and the next one is tried.
193 embed_timeout_ms: u64,
194 /// Bounded set of fire-and-forget ASI coherence update tasks.
195 ///
196 /// Shared across all clones via `Arc`; capped at [`MAX_ASI_TASKS`]. New spawns are
197 /// skipped (not aborted) when the cap is reached to preserve in-flight work.
198 asi_tasks: Arc<Mutex<tokio::task::JoinSet<()>>>,
199}
200
201#[cfg(test)]
202mod tests;