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}