Skip to main content

zeph_config/providers/
router.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Multi-provider routing strategy configuration.
5//!
6//! Declares the routing strategy selectors ([`LlmRoutingStrategy`],
7//! [`RouterStrategyConfig`]) and the per-strategy tuning structs: EMA/Thompson
8//! ([`RouterConfig`]), cascade ([`CascadeConfig`]), bandit ([`BanditConfig`]),
9//! reputation ([`ReputationConfig`]), stability index ([`AsiConfig`]), complexity
10//! triage ([`ComplexityRoutingConfig`]), and collaborative entropy ([`CoeConfig`]).
11
12use serde::{Deserialize, Serialize};
13use zeph_common::ProviderName;
14
15use super::default_true;
16
17fn default_cascade_quality_threshold() -> f64 {
18    0.5
19}
20
21fn default_cascade_max_escalations() -> u8 {
22    2
23}
24
25fn default_cascade_window_size() -> usize {
26    50
27}
28
29fn default_cascade_judge_timeout_ms() -> u64 {
30    5_000
31}
32
33fn default_reputation_decay_factor() -> f64 {
34    0.95
35}
36
37fn default_reputation_weight() -> f64 {
38    0.3
39}
40
41fn default_reputation_min_observations() -> u64 {
42    5
43}
44/// Routing strategy selection for multi-provider routing.
45#[non_exhaustive]
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
47#[serde(rename_all = "lowercase")]
48pub enum RouterStrategyConfig {
49    /// Exponential moving average latency-aware ordering.
50    #[default]
51    Ema,
52    /// Thompson Sampling with Beta distributions (persistence-backed).
53    Thompson,
54    /// Cascade routing: try cheapest provider first, escalate on degenerate output.
55    Cascade,
56    /// PILOT: `LinUCB` contextual bandit with online learning and cost-aware reward.
57    Bandit,
58}
59
60/// Agent Stability Index (ASI) configuration.
61///
62/// Tracks per-provider response coherence via a sliding window of response embeddings.
63/// When coherence drops below `coherence_threshold`, the provider's routing prior is
64/// penalized by `penalty_weight`. Disabled by default; session-only (no persistence).
65///
66/// # Known Limitation
67///
68/// ASI embeddings are computed in a background `tokio::spawn` task after the response is
69/// returned to the caller. Under high request rates, the coherence score used for routing
70/// may lag 1–2 responses behind due to this fire-and-forget design. With the default
71/// `window = 5`, this lag is tolerable — coherence is a slow-moving signal.
72#[derive(Debug, Clone, Deserialize, Serialize)]
73pub struct AsiConfig {
74    /// Enable ASI coherence tracking. Default: false.
75    #[serde(default)]
76    pub enabled: bool,
77
78    /// Sliding window size for response embeddings per provider. Default: 5.
79    #[serde(default = "default_asi_window")]
80    pub window: usize,
81
82    /// Coherence score [0.0, 1.0] below which the provider is penalized. Default: 0.7.
83    #[serde(default = "default_asi_coherence_threshold")]
84    pub coherence_threshold: f32,
85
86    /// Penalty weight applied to Thompson beta / EMA score on low coherence. Default: 0.3.
87    ///
88    /// For Thompson, this shifts the beta prior: `beta += penalty_weight * (threshold - coherence)`.
89    /// For EMA, the score is multiplied by `max(0.5, coherence / threshold)`.
90    #[serde(default = "default_asi_penalty_weight")]
91    pub penalty_weight: f32,
92}
93
94fn default_asi_window() -> usize {
95    5
96}
97
98fn default_asi_coherence_threshold() -> f32 {
99    0.7
100}
101
102fn default_asi_penalty_weight() -> f32 {
103    0.3
104}
105
106impl Default for AsiConfig {
107    fn default() -> Self {
108        Self {
109            enabled: false,
110            window: default_asi_window(),
111            coherence_threshold: default_asi_coherence_threshold(),
112            penalty_weight: default_asi_penalty_weight(),
113        }
114    }
115}
116
117/// Routing configuration for multi-provider setups.
118#[derive(Debug, Clone, Deserialize, Serialize)]
119pub struct RouterConfig {
120    /// Routing strategy: `"ema"` (default), `"thompson"`, `"cascade"`, or `"bandit"`.
121    #[serde(default)]
122    pub strategy: RouterStrategyConfig,
123    /// Path for persisting Thompson Sampling state. Defaults to `~/.zeph/router_thompson_state.json`.
124    ///
125    /// # Security
126    ///
127    /// This path is user-controlled. The application writes and reads a JSON file at
128    /// this location. Ensure the path is within a directory that is not world-writable
129    /// (e.g., avoid `/tmp`). The file is created with mode `0o600` on Unix.
130    #[serde(default)]
131    pub thompson_state_path: Option<String>,
132    /// Cascade routing configuration. Only used when `strategy = "cascade"`.
133    #[serde(default)]
134    pub cascade: Option<CascadeConfig>,
135    /// Bayesian reputation scoring configuration (RAPS). Disabled by default.
136    #[serde(default)]
137    pub reputation: Option<ReputationConfig>,
138    /// PILOT bandit routing configuration. Only used when `strategy = "bandit"`.
139    #[serde(default)]
140    pub bandit: Option<BanditConfig>,
141    /// Embedding-based quality gate threshold for Thompson/EMA routing. Default: disabled.
142    ///
143    /// When set, after provider selection, the cosine similarity between the query embedding
144    /// and the response embedding is computed. If below this threshold, the next provider in
145    /// the ordered list is tried. On exhaustion, the best response seen is returned.
146    ///
147    /// Only applies to Thompson and EMA strategies. Cascade uses its own quality classifier.
148    /// Fail-open: embedding errors disable the gate for that request.
149    #[serde(default)]
150    pub quality_gate: Option<f32>,
151    /// Agent Stability Index configuration. Disabled by default.
152    #[serde(default)]
153    pub asi: Option<AsiConfig>,
154    /// Maximum number of concurrent `embed_batch` calls through the router.
155    ///
156    /// Limits simultaneous embedding HTTP requests to prevent provider rate-limiting
157    /// and memory pressure during indexing or high-frequency recall. Default: 4.
158    /// Set to 0 to disable the semaphore (unlimited concurrency).
159    #[serde(default = "default_embed_concurrency")]
160    pub embed_concurrency: usize,
161}
162
163fn default_embed_concurrency() -> usize {
164    4
165}
166
167/// Configuration for Bayesian reputation scoring (RAPS — Reputation-Adjusted Provider Selection).
168///
169/// When enabled, quality outcomes from tool execution shift the routing scores over time,
170/// giving an advantage to providers that consistently produce valid tool arguments.
171///
172/// Default: disabled. Set `enabled = true` to activate.
173#[derive(Debug, Clone, Deserialize, Serialize)]
174pub struct ReputationConfig {
175    /// Enable reputation scoring. Default: false.
176    #[serde(default)]
177    pub enabled: bool,
178    /// Session-level decay factor applied on each load. Range: (0.0, 1.0]. Default: 0.95.
179    /// Lower values make reputation forget faster; 1.0 = no decay.
180    #[serde(default = "default_reputation_decay_factor")]
181    pub decay_factor: f64,
182    /// Weight of reputation in routing score blend. Range: [0.0, 1.0]. Default: 0.3.
183    ///
184    /// **Warning**: values above 0.5 can aggressively suppress low-reputation providers.
185    /// At `weight = 1.0` with `rep_factor = 0.0` (all failures), the routing score
186    /// drops to zero — the provider becomes unreachable for that session. Stick to
187    /// the default (0.3) unless you intentionally want strong reputation gating.
188    #[serde(default = "default_reputation_weight")]
189    pub weight: f64,
190    /// Minimum quality observations before reputation influences routing. Default: 5.
191    #[serde(default = "default_reputation_min_observations")]
192    pub min_observations: u64,
193    /// Path for persisting reputation state. Defaults to `~/.config/zeph/router_reputation_state.json`.
194    #[serde(default)]
195    pub state_path: Option<String>,
196}
197
198/// Configuration for cascade routing (`strategy = "cascade"`).
199///
200/// Cascade routing tries providers in chain order (cheapest first), escalating to
201/// the next provider when the response is classified as degenerate (empty, repetitive,
202/// incoherent). Chain order determines cost order: first provider = cheapest.
203///
204/// # Limitations
205///
206/// The heuristic classifier detects degenerate outputs only, not semantic failures.
207/// Use `classifier_mode = "judge"` for semantic quality gating (adds LLM call cost).
208#[derive(Debug, Clone, Deserialize, Serialize)]
209pub struct CascadeConfig {
210    /// Minimum quality score [0.0, 1.0] to accept a response without escalating.
211    /// Responses scoring below this threshold trigger escalation.
212    #[serde(default = "default_cascade_quality_threshold")]
213    pub quality_threshold: f64,
214
215    /// Maximum number of quality-based escalations per request.
216    /// Network/API errors do not count against this budget.
217    /// Default: 2 (allows up to 3 providers: cheap → mid → expensive).
218    #[serde(default = "default_cascade_max_escalations")]
219    pub max_escalations: u8,
220
221    /// Quality classifier mode: `"heuristic"` (default) or `"judge"`.
222    /// Heuristic is zero-cost but detects only degenerate outputs.
223    /// Judge requires a configured `summary_model` and adds one LLM call per evaluation.
224    #[serde(default)]
225    pub classifier_mode: CascadeClassifierMode,
226
227    /// Rolling quality history window size per provider. Default: 50.
228    #[serde(default = "default_cascade_window_size")]
229    pub window_size: usize,
230
231    /// Maximum cumulative input+output tokens across all escalation levels.
232    /// When exceeded, returns the best-seen response instead of escalating further.
233    /// `None` disables the budget (unbounded escalation cost).
234    #[serde(default)]
235    pub max_cascade_tokens: Option<u32>,
236
237    /// Explicit cost ordering of provider names (cheapest first).
238    /// When set, cascade routing sorts providers by their position in this list before
239    /// trying them. Providers not in the list are appended after listed ones in their
240    /// original chain order. When unset, chain order is used (default behavior).
241    #[serde(default, skip_serializing_if = "Option::is_none")]
242    pub cost_tiers: Option<Vec<String>>,
243
244    /// Hard timeout for the judge LLM call (milliseconds).
245    /// If the judge does not respond within this budget, the call is treated as a failure
246    /// and heuristic scoring is used instead. Default: 5000 (5 s).
247    #[serde(default = "default_cascade_judge_timeout_ms")]
248    pub judge_timeout_ms: u64,
249}
250
251impl Default for CascadeConfig {
252    fn default() -> Self {
253        Self {
254            quality_threshold: default_cascade_quality_threshold(),
255            max_escalations: default_cascade_max_escalations(),
256            classifier_mode: CascadeClassifierMode::default(),
257            window_size: default_cascade_window_size(),
258            max_cascade_tokens: None,
259            cost_tiers: None,
260            judge_timeout_ms: default_cascade_judge_timeout_ms(),
261        }
262    }
263}
264
265/// Quality classifier mode for cascade routing.
266#[non_exhaustive]
267#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
268#[serde(rename_all = "lowercase")]
269pub enum CascadeClassifierMode {
270    /// Zero-cost heuristic: detects degenerate outputs (empty, repetitive, incoherent).
271    /// Does not detect semantic failures (hallucinations, wrong answers).
272    #[default]
273    Heuristic,
274    /// LLM-based judge: more accurate but adds latency. Falls back to heuristic on failure.
275    /// Requires `summary_model` to be configured.
276    Judge,
277}
278
279fn default_bandit_alpha() -> f32 {
280    1.0
281}
282
283fn default_bandit_dim() -> usize {
284    32
285}
286
287fn default_bandit_cost_weight() -> f32 {
288    0.1
289}
290
291fn default_bandit_decay_factor() -> f32 {
292    1.0
293}
294
295fn default_bandit_embedding_timeout_ms() -> u64 {
296    50
297}
298
299fn default_bandit_cache_size() -> usize {
300    512
301}
302
303/// Configuration for PILOT bandit routing (`strategy = "bandit"`).
304///
305/// PILOT (Provider Intelligence via Learned Online Tuning) uses a `LinUCB` contextual
306/// bandit to learn which provider performs best for a given query context. The feature
307/// vector is derived from the query embedding (first `dim` components, L2-normalised).
308///
309/// **Cold start**: the bandit falls back to Thompson sampling for the first
310/// `10 * num_providers` queries (configurable). After warmup, `LinUCB` takes over.
311///
312/// **Embedding**: an `embedding_provider` must be set for feature vectors. If the embed
313/// call exceeds `embedding_timeout_ms` or fails, the bandit falls back to Thompson/uniform.
314/// Use a local provider (Ollama, Candle) to avoid network latency on the hot path.
315#[derive(Debug, Clone, Deserialize, Serialize)]
316pub struct BanditConfig {
317    /// `LinUCB` exploration parameter. Default: 1.0.
318    /// Higher values increase exploration; lower values favour exploitation.
319    #[serde(default = "default_bandit_alpha")]
320    pub alpha: f32,
321
322    /// Feature vector dimension (first `dim` components of the embedding).
323    ///
324    /// This is simple truncation, not PCA. The first raw embedding dimensions do not
325    /// necessarily capture the most variance. For `OpenAI` `text-embedding-3-*` models,
326    /// consider using the `dimensions` API parameter (Matryoshka embeddings) instead.
327    /// Default: 32.
328    #[serde(default = "default_bandit_dim")]
329    pub dim: usize,
330
331    /// Cost penalty weight in the reward signal: `reward = quality - cost_weight * cost_fraction`.
332    /// Default: 0.1. Increase to penalise expensive providers more aggressively.
333    #[serde(default = "default_bandit_cost_weight")]
334    pub cost_weight: f32,
335
336    /// Session-level decay applied to arm state on startup: `A = I + decay*(A-I)`, `b = decay*b`.
337    /// Values < 1.0 cause re-exploration after provider quality changes. Default: 1.0 (no decay).
338    #[serde(default = "default_bandit_decay_factor")]
339    pub decay_factor: f32,
340
341    /// Provider name from `[[llm.providers]]` used for query embeddings.
342    ///
343    /// SLM recommended: prefer a fast local model (e.g. Ollama `nomic-embed-text`,
344    /// Candle, or `text-embedding-3-small`) — this is called on every bandit request.
345    /// Empty string disables `LinUCB` (bandit always falls back to Thompson/uniform).
346    #[serde(default)]
347    pub embedding_provider: ProviderName,
348
349    /// Hard timeout for the embedding call in milliseconds. Default: 50.
350    /// If exceeded, the request falls back to Thompson/uniform selection.
351    #[serde(default = "default_bandit_embedding_timeout_ms")]
352    pub embedding_timeout_ms: u64,
353
354    /// Maximum cached embeddings (keyed by query text hash). Default: 512.
355    #[serde(default = "default_bandit_cache_size")]
356    pub cache_size: usize,
357
358    /// Path for persisting bandit state. Defaults to `~/.config/zeph/router_bandit_state.json`.
359    ///
360    /// # Security
361    ///
362    /// This path is user-controlled. The file is created with mode `0o600` on Unix.
363    /// Do not place it in world-writable directories.
364    #[serde(default)]
365    pub state_path: Option<String>,
366
367    /// MAR (Memory-Augmented Routing) confidence threshold.
368    ///
369    /// When the top-1 semantic recall score for the current query is >= this value,
370    /// the bandit biases toward cheaper providers (the answer is likely in memory).
371    /// Set to 1.0 to disable MAR. Default: 0.9.
372    #[serde(default = "default_bandit_memory_confidence_threshold")]
373    pub memory_confidence_threshold: f32,
374
375    /// Minimum number of queries before `LinUCB` takes over from Thompson warmup.
376    ///
377    /// When unset or `0`, defaults to `10 × number of providers` (computed at startup).
378    /// Set explicitly to control how long the bandit explores uniformly before
379    /// switching to context-aware routing. Setting `0` preserves the computed default.
380    #[serde(default)]
381    pub warmup_queries: Option<u64>,
382}
383
384fn default_bandit_memory_confidence_threshold() -> f32 {
385    0.9
386}
387
388impl Default for BanditConfig {
389    fn default() -> Self {
390        Self {
391            alpha: default_bandit_alpha(),
392            dim: default_bandit_dim(),
393            cost_weight: default_bandit_cost_weight(),
394            decay_factor: default_bandit_decay_factor(),
395            embedding_provider: ProviderName::default(),
396            embedding_timeout_ms: default_bandit_embedding_timeout_ms(),
397            cache_size: default_bandit_cache_size(),
398            state_path: None,
399            memory_confidence_threshold: default_bandit_memory_confidence_threshold(),
400            warmup_queries: None,
401        }
402    }
403}
404/// Routing strategy for the `[[llm.providers]]` pool.
405#[non_exhaustive]
406#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
407#[serde(rename_all = "lowercase")]
408pub enum LlmRoutingStrategy {
409    /// Single provider or first-in-pool (default).
410    #[default]
411    None,
412    /// Exponential moving average latency-aware ordering.
413    Ema,
414    /// Thompson Sampling with Beta distributions.
415    Thompson,
416    /// Cascade: try cheapest provider first, escalate on degenerate output.
417    Cascade,
418    /// Complexity triage routing: pre-classify each request, delegate to appropriate tier.
419    Triage,
420    /// PILOT: `LinUCB` contextual bandit with online learning and budget-aware reward.
421    Bandit,
422}
423
424fn default_triage_timeout_secs() -> u64 {
425    5
426}
427
428fn default_max_triage_tokens() -> u32 {
429    50
430}
431
432/// Tier-to-provider name mapping for complexity routing.
433#[derive(Debug, Clone, Default, Deserialize, Serialize)]
434pub struct TierMapping {
435    pub simple: Option<String>,
436    pub medium: Option<String>,
437    pub complex: Option<String>,
438    pub expert: Option<String>,
439}
440
441/// Configuration for complexity-based triage routing (`routing = "triage"`).
442///
443/// When `[llm] routing = "triage"` is set, a cheap triage model classifies each request
444/// and routes it to the appropriate tier provider. Requires at least one tier mapping.
445///
446/// # Example
447///
448/// ```toml
449/// [llm]
450/// routing = "triage"
451///
452/// [llm.complexity_routing]
453/// triage_provider = "local-fast"
454///
455/// [llm.complexity_routing.tiers]
456/// simple = "local-fast"
457/// medium = "haiku"
458/// complex = "sonnet"
459/// expert = "opus"
460/// ```
461#[derive(Debug, Clone, Deserialize, Serialize)]
462pub struct ComplexityRoutingConfig {
463    /// Provider name from `[[llm.providers]]` used for triage classification.
464    #[serde(default)]
465    pub triage_provider: Option<ProviderName>,
466
467    /// Skip triage when all tiers map to the same provider.
468    #[serde(default = "default_true")]
469    pub bypass_single_provider: bool,
470
471    /// Tier-to-provider name mapping.
472    #[serde(default)]
473    pub tiers: TierMapping,
474
475    /// Max output tokens for the triage classification call. Default: 50.
476    #[serde(default = "default_max_triage_tokens")]
477    pub max_triage_tokens: u32,
478
479    /// Timeout in seconds for the triage classification call. Default: 5.
480    /// On timeout, falls back to the default (first) tier provider.
481    #[serde(default = "default_triage_timeout_secs")]
482    pub triage_timeout_secs: u64,
483
484    /// Optional fallback strategy when triage misclassifies.
485    /// Only `"cascade"` is currently supported (Phase 4).
486    #[serde(default)]
487    pub fallback_strategy: Option<String>,
488}
489
490impl Default for ComplexityRoutingConfig {
491    fn default() -> Self {
492        Self {
493            triage_provider: None,
494            bypass_single_provider: true,
495            tiers: TierMapping::default(),
496            max_triage_tokens: default_max_triage_tokens(),
497            triage_timeout_secs: default_triage_timeout_secs(),
498            fallback_strategy: None,
499        }
500    }
501}
502
503/// Configuration for the Collaborative Entropy (`CoE`) subsystem (`[llm.coe]` TOML section).
504///
505/// `CoE` detects uncertain responses from the primary provider and escalates to a
506/// secondary provider when either the intra-entropy or inter-divergence signal crosses
507/// its threshold. Only active for `RouterStrategy::Ema` and `RouterStrategy::Thompson`.
508///
509/// # Example
510///
511/// ```toml
512/// [llm.coe]
513/// enabled = true
514/// intra_threshold = 0.8
515/// inter_threshold = 0.20
516/// shadow_sample_rate = 0.1
517/// secondary_provider = "quality"
518/// embedding_provider = ""
519/// ```
520#[derive(Debug, Clone, Deserialize, Serialize)]
521#[serde(default)]
522pub struct CoeConfig {
523    /// Enable `CoE`. When `false`, the struct is ignored.
524    pub enabled: bool,
525    /// Mean negative log-prob threshold; responses above this trigger intra escalation.
526    pub intra_threshold: f64,
527    /// Divergence threshold in `[0.0, 1.0]`.
528    pub inter_threshold: f64,
529    /// Baseline rate at which secondary is called even when intra is low.
530    pub shadow_sample_rate: f64,
531    /// Provider name from `[[llm.providers]]` used as the escalation target.
532    pub secondary_provider: ProviderName,
533    /// Provider name for inter-divergence embeddings. Empty → inherit bandit's embedding provider.
534    pub embedding_provider: ProviderName,
535}
536
537impl Default for CoeConfig {
538    fn default() -> Self {
539        Self {
540            enabled: false,
541            intra_threshold: 0.8,
542            inter_threshold: 0.20,
543            shadow_sample_rate: 0.1,
544            secondary_provider: ProviderName::default(),
545            embedding_provider: ProviderName::default(),
546        }
547    }
548}