Skip to main content

clawft_types/
routing.rs

1//! Routing and permission configuration types.
2//!
3//! Defines the config schema for the TieredRouter (Level 1) and its
4//! permission system. All types support both `snake_case` and `camelCase`
5//! field names in JSON via `#[serde(alias)]`. Unknown fields are silently
6//! ignored for forward compatibility.
7//!
8//! When the `routing` section is absent from config, `RoutingConfig::default()`
9//! produces settings equivalent to the existing `StaticRouter` (Level 0).
10
11use std::collections::HashMap;
12
13use serde::{Deserialize, Serialize};
14
15// ── TierSelectionStrategy ────────────────────────────────────────────────
16
17/// Strategy for selecting a model within a tier.
18///
19/// Controls how the router picks among multiple models in a single tier.
20/// Serializes/deserializes as snake_case strings (e.g., `"preference_order"`).
21#[non_exhaustive]
22#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
23#[serde(rename_all = "snake_case")]
24pub enum TierSelectionStrategy {
25    /// Use models in the order listed (first available wins).
26    PreferenceOrder,
27    /// Rotate through models in round-robin fashion.
28    RoundRobin,
29    /// Pick the cheapest model in the tier.
30    LowestCost,
31    /// Pick a random model from the tier.
32    Random,
33}
34
35// ── RoutingConfig ────────────────────────────────────────────────────────
36
37/// Top-level routing configuration.
38///
39/// Added to the root `Config` struct alongside `agents`, `channels`, etc.
40/// When absent from JSON, defaults to `mode = "static"` (Level 0 StaticRouter).
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct RoutingConfig {
43    /// Routing mode: `"static"` (default, Level 0) or `"tiered"` (Level 1).
44    #[serde(default = "default_routing_mode")]
45    pub mode: String,
46
47    /// Model tier definitions, ordered cheapest to most expensive.
48    /// Only used when `mode = "tiered"`.
49    #[serde(default)]
50    pub tiers: Vec<ModelTierConfig>,
51
52    /// Model selection strategy within a tier.
53    #[serde(default, alias = "selectionStrategy")]
54    pub selection_strategy: Option<TierSelectionStrategy>,
55
56    /// Fallback model when all tiers/budgets are exhausted.
57    /// Format: `"provider/model"` (e.g., `"groq/llama-3.1-8b"`).
58    #[serde(default, alias = "fallbackModel")]
59    pub fallback_model: Option<String>,
60
61    /// Permission level definitions and per-user/channel overrides.
62    #[serde(default)]
63    pub permissions: PermissionsConfig,
64
65    /// Escalation behavior settings.
66    #[serde(default)]
67    pub escalation: EscalationConfig,
68
69    /// Global cost budget settings.
70    #[serde(default, alias = "costBudgets")]
71    pub cost_budgets: CostBudgetConfig,
72
73    /// Rate limiting settings.
74    #[serde(default, alias = "rateLimiting")]
75    pub rate_limiting: RateLimitConfig,
76}
77
78fn default_routing_mode() -> String {
79    "static".into()
80}
81
82impl Default for RoutingConfig {
83    fn default() -> Self {
84        Self {
85            mode: default_routing_mode(),
86            tiers: Vec::new(),
87            selection_strategy: None,
88            fallback_model: None,
89            permissions: PermissionsConfig::default(),
90            escalation: EscalationConfig::default(),
91            cost_budgets: CostBudgetConfig::default(),
92            rate_limiting: RateLimitConfig::default(),
93        }
94    }
95}
96
97// ── ModelTierConfig ──────────────────────────────────────────────────────
98
99/// A named group of models at a similar cost/capability level.
100///
101/// Tiers are ordered from cheapest to most expensive in the `tiers` array.
102/// Complexity ranges may overlap intentionally -- the router picks the
103/// highest-quality tier the user is allowed and can afford.
104#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct ModelTierConfig {
106    /// Tier name (e.g., `"free"`, `"standard"`, `"premium"`, `"elite"`).
107    pub name: String,
108
109    /// Models available in this tier, in preference order.
110    /// Format: `"provider/model"` (e.g., `"anthropic/claude-haiku-3.5"`).
111    #[serde(default)]
112    pub models: Vec<String>,
113
114    /// Complexity range this tier covers: `[min, max]` where each is 0.0-1.0.
115    #[serde(default = "default_complexity_range", alias = "complexityRange")]
116    pub complexity_range: [f32; 2],
117
118    /// Approximate cost per 1K tokens (blended input/output) in USD.
119    #[serde(default, alias = "costPer1kTokens")]
120    pub cost_per_1k_tokens: f64,
121
122    /// Maximum context tokens supported by models in this tier.
123    #[serde(default = "default_tier_max_context", alias = "maxContextTokens")]
124    pub max_context_tokens: usize,
125}
126
127fn default_complexity_range() -> [f32; 2] {
128    [0.0, 1.0]
129}
130
131fn default_tier_max_context() -> usize {
132    8192
133}
134
135impl Default for ModelTierConfig {
136    fn default() -> Self {
137        Self {
138            name: String::new(),
139            models: Vec::new(),
140            complexity_range: default_complexity_range(),
141            cost_per_1k_tokens: 0.0,
142            max_context_tokens: default_tier_max_context(),
143        }
144    }
145}
146
147// ── PermissionsConfig ────────────────────────────────────────────────────
148
149/// Container for permission level defaults and per-user/channel overrides.
150///
151/// The three built-in levels (`zero_trust`, `user`, `admin`) are named fields.
152/// Per-user and per-channel overrides use HashMaps keyed by sender ID or
153/// channel name.
154#[derive(Debug, Clone, Serialize, Deserialize, Default)]
155pub struct PermissionsConfig {
156    /// Level 0 (zero-trust) permission defaults.
157    #[serde(default)]
158    pub zero_trust: PermissionLevelConfig,
159
160    /// Level 1 (user) permission defaults.
161    #[serde(default)]
162    pub user: PermissionLevelConfig,
163
164    /// Level 2 (admin) permission defaults.
165    #[serde(default)]
166    pub admin: PermissionLevelConfig,
167
168    /// Per-user permission overrides, keyed by sender ID.
169    #[serde(default)]
170    pub users: HashMap<String, PermissionLevelConfig>,
171
172    /// Per-channel permission overrides, keyed by channel name.
173    #[serde(default)]
174    pub channels: HashMap<String, PermissionLevelConfig>,
175}
176
177// ── PermissionLevelConfig ────────────────────────────────────────────────
178
179/// Configuration for a single permission level or override.
180///
181/// When used as a level default, all fields are meaningful. When used as
182/// a per-user or per-channel override, only specified fields apply --
183/// the rest inherit from the user's resolved level defaults.
184///
185/// All fields are `Option` to support partial overrides.
186#[derive(Debug, Clone, Serialize, Deserialize, Default)]
187pub struct PermissionLevelConfig {
188    /// Permission level (0 = zero_trust, 1 = user, 2 = admin).
189    #[serde(default)]
190    pub level: Option<u8>,
191
192    /// Maximum model tier this user can access.
193    #[serde(default, alias = "maxTier")]
194    pub max_tier: Option<String>,
195
196    /// Explicit model allowlist. Empty = all models in allowed tiers.
197    #[serde(default, alias = "modelAccess")]
198    pub model_access: Option<Vec<String>>,
199
200    /// Explicit model denylist. Checked after allowlist.
201    #[serde(default, alias = "modelDenylist")]
202    pub model_denylist: Option<Vec<String>>,
203
204    /// Tool names this user can invoke. `["*"]` = all tools.
205    #[serde(default, alias = "toolAccess")]
206    pub tool_access: Option<Vec<String>>,
207
208    /// Tool names explicitly denied even if tool_access allows.
209    #[serde(default, alias = "toolDenylist")]
210    pub tool_denylist: Option<Vec<String>>,
211
212    /// Maximum input context tokens.
213    #[serde(default, alias = "maxContextTokens")]
214    pub max_context_tokens: Option<usize>,
215
216    /// Maximum output tokens per response.
217    #[serde(default, alias = "maxOutputTokens")]
218    pub max_output_tokens: Option<usize>,
219
220    /// Rate limit in requests per minute. 0 = unlimited.
221    #[serde(default, alias = "rateLimit")]
222    pub rate_limit: Option<u32>,
223
224    /// Whether SSE streaming responses are allowed.
225    #[serde(default, alias = "streamingAllowed")]
226    pub streaming_allowed: Option<bool>,
227
228    /// Whether complexity-based escalation to a higher tier is allowed.
229    #[serde(default, alias = "escalationAllowed")]
230    pub escalation_allowed: Option<bool>,
231
232    /// Complexity threshold (0.0-1.0) above which escalation triggers.
233    #[serde(default, alias = "escalationThreshold")]
234    pub escalation_threshold: Option<f32>,
235
236    /// Whether the user can manually override model selection.
237    #[serde(default, alias = "modelOverride")]
238    pub model_override: Option<bool>,
239
240    /// Daily cost budget in USD. 0.0 = unlimited.
241    #[serde(default, alias = "costBudgetDailyUsd")]
242    pub cost_budget_daily_usd: Option<f64>,
243
244    /// Monthly cost budget in USD. 0.0 = unlimited.
245    #[serde(default, alias = "costBudgetMonthlyUsd")]
246    pub cost_budget_monthly_usd: Option<f64>,
247
248    /// Extensible custom permission dimensions.
249    #[serde(default, alias = "customPermissions")]
250    pub custom_permissions: Option<HashMap<String, serde_json::Value>>,
251}
252
253// ── UserPermissions ──────────────────────────────────────────────────────
254
255/// Resolved user permission capabilities.
256///
257/// This is the **runtime** permission object produced by layering:
258/// built-in defaults + level config + workspace config + user override +
259/// channel override. Unlike `PermissionLevelConfig` (which uses `Option`
260/// for partial overrides), all fields here are concrete values.
261#[derive(Debug, Clone, Serialize, Deserialize)]
262pub struct UserPermissions {
263    /// Permission level (0 = zero_trust, 1 = user, 2 = admin).
264    #[serde(default)]
265    pub level: u8,
266
267    /// Maximum model tier this user can access.
268    #[serde(default, alias = "maxTier")]
269    pub max_tier: String,
270
271    /// Explicit model allowlist. Empty = all models in allowed tiers.
272    #[serde(default, alias = "modelAccess")]
273    pub model_access: Vec<String>,
274
275    /// Explicit model denylist.
276    #[serde(default, alias = "modelDenylist")]
277    pub model_denylist: Vec<String>,
278
279    /// Tool names this user can invoke. `["*"]` = all tools.
280    #[serde(default, alias = "toolAccess")]
281    pub tool_access: Vec<String>,
282
283    /// Tool names explicitly denied.
284    #[serde(default, alias = "toolDenylist")]
285    pub tool_denylist: Vec<String>,
286
287    /// Maximum input context tokens.
288    #[serde(default = "default_max_context_tokens", alias = "maxContextTokens")]
289    pub max_context_tokens: usize,
290
291    /// Maximum output tokens per response.
292    #[serde(default = "default_max_output_tokens", alias = "maxOutputTokens")]
293    pub max_output_tokens: usize,
294
295    /// Rate limit in requests per minute. 0 = unlimited.
296    #[serde(default = "default_rate_limit", alias = "rateLimit")]
297    pub rate_limit: u32,
298
299    /// Whether SSE streaming responses are allowed.
300    #[serde(default, alias = "streamingAllowed")]
301    pub streaming_allowed: bool,
302
303    /// Whether complexity-based escalation is allowed.
304    #[serde(default, alias = "escalationAllowed")]
305    pub escalation_allowed: bool,
306
307    /// Complexity threshold (0.0-1.0) above which escalation triggers.
308    #[serde(default = "default_escalation_threshold", alias = "escalationThreshold")]
309    pub escalation_threshold: f32,
310
311    /// Whether the user can manually override model selection.
312    #[serde(default, alias = "modelOverride")]
313    pub model_override: bool,
314
315    /// Daily cost budget in USD. 0.0 = unlimited.
316    /// Zero-trust default: $0.10/day (see design doc Section 2.2).
317    #[serde(default = "default_cost_budget_daily_usd", alias = "costBudgetDailyUsd")]
318    pub cost_budget_daily_usd: f64,
319
320    /// Monthly cost budget in USD. 0.0 = unlimited.
321    /// Zero-trust default: $2.00/month (see design doc Section 2.2).
322    #[serde(default = "default_cost_budget_monthly_usd", alias = "costBudgetMonthlyUsd")]
323    pub cost_budget_monthly_usd: f64,
324
325    /// Extensible custom permission dimensions.
326    #[serde(default, alias = "customPermissions")]
327    pub custom_permissions: HashMap<String, serde_json::Value>,
328}
329
330fn default_cost_budget_daily_usd() -> f64 {
331    0.10
332}
333
334fn default_cost_budget_monthly_usd() -> f64 {
335    2.00
336}
337
338fn default_max_context_tokens() -> usize {
339    4096
340}
341
342fn default_max_output_tokens() -> usize {
343    1024
344}
345
346fn default_rate_limit() -> u32 {
347    10
348}
349
350fn default_escalation_threshold() -> f32 {
351    1.0
352}
353
354impl Default for UserPermissions {
355    /// Returns zero-trust defaults. All values are restrictive.
356    /// `cost_budget_daily_usd` = $0.10, `cost_budget_monthly_usd` = $2.00
357    /// per design doc Section 2.2 (NOT 0.0, which would mean unlimited).
358    fn default() -> Self {
359        Self {
360            level: 0,
361            max_tier: "free".into(),
362            model_access: Vec::new(),
363            model_denylist: Vec::new(),
364            tool_access: Vec::new(),
365            tool_denylist: Vec::new(),
366            max_context_tokens: default_max_context_tokens(),
367            max_output_tokens: default_max_output_tokens(),
368            rate_limit: default_rate_limit(),
369            streaming_allowed: false,
370            escalation_allowed: false,
371            escalation_threshold: default_escalation_threshold(),
372            model_override: false,
373            cost_budget_daily_usd: default_cost_budget_daily_usd(),
374            cost_budget_monthly_usd: default_cost_budget_monthly_usd(),
375            custom_permissions: HashMap::new(),
376        }
377    }
378}
379
380// ── AuthContext ───────────────────────────────────────────────────────────
381
382/// Authentication context threaded through the request pipeline.
383///
384/// Attached to `ChatRequest` by the agent loop after resolving the sender's
385/// identity from channel authentication. When absent, the router defaults
386/// to zero-trust permissions.
387///
388/// `Default` returns zero-trust values (empty sender_id, empty channel,
389/// zero-trust permissions). For CLI use, call `AuthContext::cli_default()`
390/// which sets sender_id="local", channel="cli", and admin permissions.
391#[derive(Debug, Clone, Serialize, Deserialize)]
392pub struct AuthContext {
393    /// Unique sender identifier (platform-specific).
394    /// Telegram: user ID, Slack: user ID, Discord: user ID, CLI: `"local"`.
395    #[serde(default, alias = "senderId")]
396    pub sender_id: String,
397
398    /// Channel name the request originated from.
399    #[serde(default)]
400    pub channel: String,
401
402    /// Resolved permissions for this sender.
403    #[serde(default)]
404    pub permissions: UserPermissions,
405}
406
407impl Default for AuthContext {
408    /// Returns zero-trust defaults: empty sender_id, empty channel,
409    /// zero-trust permissions. Unauthenticated requests get minimal access.
410    fn default() -> Self {
411        Self {
412            sender_id: String::new(),
413            channel: String::new(),
414            permissions: UserPermissions::default(),
415        }
416    }
417}
418
419impl AuthContext {
420    /// Convenience constructor for CLI use. Sets `sender_id = "local"`,
421    /// `channel = "cli"`, and admin-level permissions. Callers must
422    /// explicitly opt into CLI privileges -- this is NOT the Default.
423    pub fn cli_default() -> Self {
424        Self {
425            sender_id: "local".into(),
426            channel: "cli".into(),
427            permissions: UserPermissions {
428                level: 2,
429                max_tier: "elite".into(),
430                tool_access: vec!["*".into()],
431                max_context_tokens: 200_000,
432                max_output_tokens: 16_384,
433                rate_limit: 0,
434                streaming_allowed: true,
435                escalation_allowed: true,
436                escalation_threshold: 0.0,
437                model_override: true,
438                cost_budget_daily_usd: 0.0,
439                cost_budget_monthly_usd: 0.0,
440                ..UserPermissions::default()
441            },
442        }
443    }
444}
445
446// ── EscalationConfig ─────────────────────────────────────────────────────
447
448/// Controls complexity-based escalation to higher model tiers.
449#[derive(Debug, Clone, Serialize, Deserialize)]
450pub struct EscalationConfig {
451    /// Whether escalation is enabled globally.
452    #[serde(default)]
453    pub enabled: bool,
454
455    /// Default complexity threshold for escalation (0.0-1.0).
456    #[serde(default = "default_global_escalation_threshold")]
457    pub threshold: f32,
458
459    /// Maximum number of tiers a request can escalate beyond the user's `max_tier`.
460    #[serde(default = "default_max_escalation_tiers", alias = "maxEscalationTiers")]
461    pub max_escalation_tiers: u32,
462}
463
464fn default_global_escalation_threshold() -> f32 {
465    0.6
466}
467
468fn default_max_escalation_tiers() -> u32 {
469    1
470}
471
472impl Default for EscalationConfig {
473    fn default() -> Self {
474        Self {
475            enabled: false,
476            threshold: default_global_escalation_threshold(),
477            max_escalation_tiers: default_max_escalation_tiers(),
478        }
479    }
480}
481
482// ── CostBudgetConfig ─────────────────────────────────────────────────────
483
484/// Global cost budget settings.
485///
486/// System-wide limits that apply regardless of individual user budgets.
487#[derive(Debug, Clone, Serialize, Deserialize)]
488pub struct CostBudgetConfig {
489    /// Global daily spending limit in USD. 0.0 = unlimited.
490    #[serde(default, alias = "globalDailyLimitUsd")]
491    pub global_daily_limit_usd: f64,
492
493    /// Global monthly spending limit in USD. 0.0 = unlimited.
494    #[serde(default, alias = "globalMonthlyLimitUsd")]
495    pub global_monthly_limit_usd: f64,
496
497    /// Whether to persist cost tracking data to disk.
498    #[serde(default, alias = "trackingPersistence")]
499    pub tracking_persistence: bool,
500
501    /// Hour (UTC) at which daily budgets reset. 0 = midnight UTC.
502    #[serde(default, alias = "resetHourUtc")]
503    pub reset_hour_utc: u8,
504}
505
506impl Default for CostBudgetConfig {
507    fn default() -> Self {
508        Self {
509            global_daily_limit_usd: 0.0,
510            global_monthly_limit_usd: 0.0,
511            tracking_persistence: false,
512            reset_hour_utc: 0,
513        }
514    }
515}
516
517// ── RateLimitConfig ──────────────────────────────────────────────────────
518
519/// Rate limiting configuration.
520///
521/// Controls the sliding-window rate limiter that enforces per-user request
522/// limits. The window size and strategy are global; per-user limits are
523/// defined in `PermissionLevelConfig.rate_limit`.
524#[derive(Debug, Clone, Serialize, Deserialize)]
525pub struct RateLimitConfig {
526    /// Window size in seconds for rate limit calculations.
527    #[serde(default = "default_window_seconds", alias = "windowSeconds")]
528    pub window_seconds: u32,
529
530    /// Rate limiting strategy: `"sliding_window"` (default) or `"fixed_window"`.
531    #[serde(default = "default_rate_limit_strategy")]
532    pub strategy: String,
533
534    /// Global rate limit in requests per minute across ALL users.
535    /// 0 = unlimited (no global cap). Checked before per-user limits.
536    #[serde(default, alias = "globalRateLimitRpm")]
537    pub global_rate_limit_rpm: u32,
538}
539
540fn default_window_seconds() -> u32 {
541    60
542}
543
544fn default_rate_limit_strategy() -> String {
545    "sliding_window".into()
546}
547
548impl Default for RateLimitConfig {
549    fn default() -> Self {
550        Self {
551            window_seconds: default_window_seconds(),
552            strategy: default_rate_limit_strategy(),
553            global_rate_limit_rpm: 0,
554        }
555    }
556}
557
558// ── Tests ────────────────────────────────────────────────────────────────
559
560#[cfg(test)]
561mod tests {
562    use super::*;
563
564    const TIERED_FIXTURE_PATH: &str = concat!(
565        env!("CARGO_MANIFEST_DIR"),
566        "/../../tests/fixtures/config_tiered.json"
567    );
568
569    fn load_tiered_fixture() -> crate::config::Config {
570        let content = std::fs::read_to_string(TIERED_FIXTURE_PATH)
571            .expect("config_tiered.json fixture should exist");
572        serde_json::from_str(&content).expect("tiered fixture should deserialize")
573    }
574
575    #[test]
576    fn routing_config_defaults() {
577        let cfg = RoutingConfig::default();
578        assert_eq!(cfg.mode, "static");
579        assert!(cfg.tiers.is_empty());
580        assert!(cfg.selection_strategy.is_none());
581        assert!(cfg.fallback_model.is_none());
582        assert!(!cfg.escalation.enabled);
583    }
584
585    #[test]
586    fn model_tier_config_defaults() {
587        let cfg = ModelTierConfig::default();
588        assert!(cfg.name.is_empty());
589        assert!(cfg.models.is_empty());
590        assert_eq!(cfg.complexity_range, [0.0, 1.0]);
591        assert_eq!(cfg.cost_per_1k_tokens, 0.0);
592        assert_eq!(cfg.max_context_tokens, 8192);
593    }
594
595    #[test]
596    fn permission_level_config_defaults() {
597        let cfg = PermissionLevelConfig::default();
598        assert!(cfg.level.is_none());
599        assert!(cfg.max_tier.is_none());
600        assert!(cfg.tool_access.is_none());
601        assert!(cfg.rate_limit.is_none());
602        assert!(cfg.streaming_allowed.is_none());
603    }
604
605    #[test]
606    fn user_permissions_defaults() {
607        let perms = UserPermissions::default();
608        assert_eq!(perms.level, 0);
609        assert_eq!(perms.max_tier, "free");
610        assert!(perms.tool_access.is_empty());
611        assert_eq!(perms.max_context_tokens, 4096);
612        assert_eq!(perms.max_output_tokens, 1024);
613        assert_eq!(perms.rate_limit, 10);
614        assert!(!perms.streaming_allowed);
615        assert!(!perms.escalation_allowed);
616        assert!((perms.escalation_threshold - 1.0).abs() < f32::EPSILON);
617        assert!(!perms.model_override);
618        assert!((perms.cost_budget_daily_usd - 0.10).abs() < f64::EPSILON);
619        assert!((perms.cost_budget_monthly_usd - 2.00).abs() < f64::EPSILON);
620        assert!(perms.custom_permissions.is_empty());
621    }
622
623    #[test]
624    fn auth_context_defaults() {
625        let ctx = AuthContext::default();
626        assert!(ctx.sender_id.is_empty());
627        assert!(ctx.channel.is_empty());
628        assert_eq!(ctx.permissions.level, 0);
629        assert!((ctx.permissions.cost_budget_daily_usd - 0.10).abs() < f64::EPSILON);
630        assert!((ctx.permissions.cost_budget_monthly_usd - 2.00).abs() < f64::EPSILON);
631    }
632
633    #[test]
634    fn auth_context_cli_default() {
635        let ctx = AuthContext::cli_default();
636        assert_eq!(ctx.sender_id, "local");
637        assert_eq!(ctx.channel, "cli");
638        assert_eq!(ctx.permissions.level, 2);
639        assert_eq!(ctx.permissions.max_tier, "elite");
640        assert_eq!(ctx.permissions.tool_access, vec!["*"]);
641        assert_eq!(ctx.permissions.max_context_tokens, 200_000);
642        assert_eq!(ctx.permissions.max_output_tokens, 16_384);
643        assert_eq!(ctx.permissions.rate_limit, 0);
644        assert!(ctx.permissions.streaming_allowed);
645        assert!(ctx.permissions.escalation_allowed);
646        assert!((ctx.permissions.escalation_threshold - 0.0).abs() < f32::EPSILON);
647        assert!(ctx.permissions.model_override);
648        assert_eq!(ctx.permissions.cost_budget_daily_usd, 0.0);
649        assert_eq!(ctx.permissions.cost_budget_monthly_usd, 0.0);
650    }
651
652    #[test]
653    fn tier_selection_strategy_serde() {
654        let json = serde_json::to_string(&TierSelectionStrategy::PreferenceOrder).unwrap();
655        assert_eq!(json, "\"preference_order\"");
656
657        let json = serde_json::to_string(&TierSelectionStrategy::RoundRobin).unwrap();
658        assert_eq!(json, "\"round_robin\"");
659
660        let json = serde_json::to_string(&TierSelectionStrategy::LowestCost).unwrap();
661        assert_eq!(json, "\"lowest_cost\"");
662
663        let json = serde_json::to_string(&TierSelectionStrategy::Random).unwrap();
664        assert_eq!(json, "\"random\"");
665
666        let strategy: TierSelectionStrategy =
667            serde_json::from_str("\"preference_order\"").unwrap();
668        assert_eq!(strategy, TierSelectionStrategy::PreferenceOrder);
669
670        let strategy: TierSelectionStrategy =
671            serde_json::from_str("\"round_robin\"").unwrap();
672        assert_eq!(strategy, TierSelectionStrategy::RoundRobin);
673
674        let result = serde_json::from_str::<TierSelectionStrategy>("\"invalid_strategy\"");
675        assert!(result.is_err());
676    }
677
678    #[test]
679    fn escalation_config_defaults() {
680        let cfg = EscalationConfig::default();
681        assert!(!cfg.enabled);
682        assert!((cfg.threshold - 0.6).abs() < f32::EPSILON);
683        assert_eq!(cfg.max_escalation_tiers, 1);
684    }
685
686    #[test]
687    fn cost_budget_config_defaults() {
688        let cfg = CostBudgetConfig::default();
689        assert_eq!(cfg.global_daily_limit_usd, 0.0);
690        assert_eq!(cfg.global_monthly_limit_usd, 0.0);
691        assert!(!cfg.tracking_persistence);
692        assert_eq!(cfg.reset_hour_utc, 0);
693    }
694
695    #[test]
696    fn rate_limit_config_defaults() {
697        let cfg = RateLimitConfig::default();
698        assert_eq!(cfg.window_seconds, 60);
699        assert_eq!(cfg.strategy, "sliding_window");
700        assert_eq!(cfg.global_rate_limit_rpm, 0);
701    }
702
703    #[test]
704    fn deserialize_full_tiered_config() {
705        let cfg = load_tiered_fixture();
706        let routing = &cfg.routing;
707
708        assert_eq!(routing.mode, "tiered");
709
710        assert_eq!(routing.tiers.len(), 4);
711        assert_eq!(routing.tiers[0].name, "free");
712        assert_eq!(routing.tiers[0].models.len(), 2);
713        assert_eq!(routing.tiers[0].complexity_range, [0.0, 0.3]);
714        assert_eq!(routing.tiers[0].cost_per_1k_tokens, 0.0);
715        assert_eq!(routing.tiers[0].max_context_tokens, 8192);
716
717        assert_eq!(routing.tiers[1].name, "standard");
718        assert_eq!(routing.tiers[2].name, "premium");
719        assert_eq!(routing.tiers[3].name, "elite");
720        assert_eq!(routing.tiers[3].cost_per_1k_tokens, 0.05);
721        assert_eq!(routing.tiers[3].max_context_tokens, 200000);
722
723        assert_eq!(
724            routing.selection_strategy,
725            Some(TierSelectionStrategy::PreferenceOrder)
726        );
727        assert_eq!(routing.fallback_model.as_deref(), Some("groq/llama-3.1-8b"));
728
729        let zt = &routing.permissions.zero_trust;
730        assert_eq!(zt.level, Some(0));
731        assert_eq!(zt.max_tier.as_deref(), Some("free"));
732        assert_eq!(zt.tool_access.as_ref().map(|v| v.len()), Some(0));
733        assert_eq!(zt.max_context_tokens, Some(4096));
734        assert_eq!(zt.max_output_tokens, Some(1024));
735        assert_eq!(zt.rate_limit, Some(10));
736        assert_eq!(zt.streaming_allowed, Some(false));
737        assert_eq!(zt.escalation_allowed, Some(false));
738
739        let u = &routing.permissions.user;
740        assert_eq!(u.level, Some(1));
741        assert_eq!(u.max_tier.as_deref(), Some("standard"));
742        assert_eq!(u.tool_access.as_ref().map(|v| v.len()), Some(7));
743        assert_eq!(u.streaming_allowed, Some(true));
744        assert_eq!(u.escalation_allowed, Some(true));
745
746        let a = &routing.permissions.admin;
747        assert_eq!(a.level, Some(2));
748        assert_eq!(a.max_tier.as_deref(), Some("elite"));
749        assert_eq!(a.model_override, Some(true));
750        assert_eq!(a.rate_limit, Some(0));
751
752        assert!(routing.permissions.users.contains_key("alice_telegram_123"));
753        assert_eq!(
754            routing.permissions.users["alice_telegram_123"].level,
755            Some(2)
756        );
757        assert!(routing.permissions.users.contains_key("bob_discord_456"));
758        assert_eq!(routing.permissions.users["bob_discord_456"].level, Some(1));
759        assert_eq!(
760            routing.permissions.users["bob_discord_456"].cost_budget_daily_usd,
761            Some(2.00)
762        );
763
764        assert_eq!(routing.permissions.channels["cli"].level, Some(2));
765        assert_eq!(routing.permissions.channels["telegram"].level, Some(1));
766        assert_eq!(routing.permissions.channels["discord"].level, Some(0));
767
768        assert!(routing.escalation.enabled);
769        assert!((routing.escalation.threshold - 0.6).abs() < f32::EPSILON);
770        assert_eq!(routing.escalation.max_escalation_tiers, 1);
771
772        assert_eq!(routing.cost_budgets.global_daily_limit_usd, 50.0);
773        assert_eq!(routing.cost_budgets.global_monthly_limit_usd, 500.0);
774        assert!(routing.cost_budgets.tracking_persistence);
775        assert_eq!(routing.cost_budgets.reset_hour_utc, 0);
776
777        assert_eq!(routing.rate_limiting.window_seconds, 60);
778        assert_eq!(routing.rate_limiting.strategy, "sliding_window");
779        assert_eq!(routing.rate_limiting.global_rate_limit_rpm, 0);
780    }
781
782    #[test]
783    fn serde_roundtrip_routing_config() {
784        let cfg = load_tiered_fixture();
785        let json = serde_json::to_string(&cfg.routing).unwrap();
786        let restored: RoutingConfig = serde_json::from_str(&json).unwrap();
787        assert_eq!(restored.mode, cfg.routing.mode);
788        assert_eq!(restored.tiers.len(), cfg.routing.tiers.len());
789        assert_eq!(restored.tiers[0].name, cfg.routing.tiers[0].name);
790        assert_eq!(restored.fallback_model, cfg.routing.fallback_model);
791        assert_eq!(
792            restored.escalation.max_escalation_tiers,
793            cfg.routing.escalation.max_escalation_tiers
794        );
795    }
796
797    #[test]
798    fn camel_case_aliases() {
799        let json = r#"{
800            "mode": "tiered",
801            "selectionStrategy": "round_robin",
802            "fallbackModel": "groq/llama-3.1-8b",
803            "costBudgets": {
804                "globalDailyLimitUsd": 25.0,
805                "globalMonthlyLimitUsd": 250.0,
806                "trackingPersistence": true,
807                "resetHourUtc": 6
808            },
809            "rateLimiting": {
810                "windowSeconds": 120
811            },
812            "escalation": {
813                "maxEscalationTiers": 2
814            }
815        }"#;
816        let cfg: RoutingConfig = serde_json::from_str(json).unwrap();
817        assert_eq!(
818            cfg.selection_strategy,
819            Some(TierSelectionStrategy::RoundRobin)
820        );
821        assert_eq!(cfg.fallback_model.as_deref(), Some("groq/llama-3.1-8b"));
822        assert_eq!(cfg.cost_budgets.global_daily_limit_usd, 25.0);
823        assert_eq!(cfg.cost_budgets.global_monthly_limit_usd, 250.0);
824        assert!(cfg.cost_budgets.tracking_persistence);
825        assert_eq!(cfg.cost_budgets.reset_hour_utc, 6);
826        assert_eq!(cfg.rate_limiting.window_seconds, 120);
827        assert_eq!(cfg.escalation.max_escalation_tiers, 2);
828    }
829
830    #[test]
831    fn unknown_fields_ignored() {
832        let json = r#"{
833            "mode": "tiered",
834            "future_field": "should be ignored",
835            "escalation": {
836                "enabled": true,
837                "unknown_nested": 42
838            },
839            "tiers": [{
840                "name": "test",
841                "models": [],
842                "complexity_range": [0.0, 1.0],
843                "cost_per_1k_tokens": 0.0,
844                "some_future_field": true
845            }]
846        }"#;
847        let cfg: RoutingConfig = serde_json::from_str(json).unwrap();
848        assert_eq!(cfg.mode, "tiered");
849        assert!(cfg.escalation.enabled);
850        assert_eq!(cfg.tiers.len(), 1);
851        assert_eq!(cfg.tiers[0].name, "test");
852    }
853
854    #[test]
855    fn empty_routing_section() {
856        let json = r#"{}"#;
857        let cfg: RoutingConfig = serde_json::from_str(json).unwrap();
858        assert_eq!(cfg.mode, "static");
859        assert!(cfg.tiers.is_empty());
860        assert!(cfg.selection_strategy.is_none());
861        assert!(cfg.fallback_model.is_none());
862        assert!(!cfg.escalation.enabled);
863    }
864
865    #[test]
866    fn backward_compat_no_routing() {
867        let json = r#"{
868            "agents": { "defaults": { "model": "deepseek/deepseek-chat" } },
869            "providers": { "anthropic": { "apiKey": "test" } }
870        }"#;
871        let cfg: crate::config::Config = serde_json::from_str(json).unwrap();
872        assert_eq!(cfg.agents.defaults.model, "deepseek/deepseek-chat");
873        assert_eq!(cfg.routing.mode, "static");
874        assert!(cfg.routing.tiers.is_empty());
875    }
876
877    #[test]
878    fn per_user_partial_override() {
879        let json = r#"{
880            "users": {
881                "alice": { "level": 2 },
882                "bob": { "level": 1, "cost_budget_daily_usd": 3.50 }
883            }
884        }"#;
885        let cfg: PermissionsConfig = serde_json::from_str(json).unwrap();
886        assert_eq!(cfg.users["alice"].level, Some(2));
887        assert!(cfg.users["alice"].max_tier.is_none());
888        assert!(cfg.users["alice"].tool_access.is_none());
889        assert_eq!(cfg.users["bob"].level, Some(1));
890        assert_eq!(cfg.users["bob"].cost_budget_daily_usd, Some(3.50));
891    }
892
893    #[test]
894    fn complexity_range_array() {
895        let json = r#"{
896            "name": "test",
897            "models": ["provider/model"],
898            "complexity_range": [0.3, 0.7],
899            "cost_per_1k_tokens": 0.005
900        }"#;
901        let tier: ModelTierConfig = serde_json::from_str(json).unwrap();
902        assert_eq!(tier.complexity_range, [0.3, 0.7]);
903        assert_eq!(tier.name, "test");
904        assert_eq!(tier.models, vec!["provider/model"]);
905        assert_eq!(tier.cost_per_1k_tokens, 0.005);
906    }
907
908    // ── Phase F: Auth context safety tests ──────────────────────────
909
910    /// F-17: UserPermissions::default() returns zero-trust (level 0).
911    /// This verifies that any unknown/unconfigured user gets the safest defaults.
912    #[test]
913    fn test_defaults_for_level_unknown_returns_zero_trust() {
914        let perms = UserPermissions::default();
915        assert_eq!(perms.level, 0, "default permissions should be zero-trust (level 0)");
916        assert_eq!(perms.max_tier, "free", "zero-trust should have 'free' tier");
917        assert!(
918            perms.tool_access.is_empty(),
919            "zero-trust should have no tool access"
920        );
921        assert!(!perms.streaming_allowed, "zero-trust should not allow streaming");
922        assert!(!perms.escalation_allowed, "zero-trust should not allow escalation");
923        assert!(!perms.model_override, "zero-trust should not allow model override");
924    }
925
926    /// F-extra: AuthContext::default() returns zero-trust with empty identity.
927    #[test]
928    fn test_auth_context_default_is_zero_trust() {
929        let ctx = AuthContext::default();
930        assert!(ctx.sender_id.is_empty(), "default sender_id should be empty");
931        assert!(ctx.channel.is_empty(), "default channel should be empty");
932        assert_eq!(ctx.permissions.level, 0, "default permissions should be level 0");
933    }
934
935    /// F-extra: AuthContext::cli_default() returns admin with correct identity.
936    #[test]
937    fn test_auth_context_cli_default_is_admin() {
938        let ctx = AuthContext::cli_default();
939        assert_eq!(ctx.sender_id, "local");
940        assert_eq!(ctx.channel, "cli");
941        assert_eq!(ctx.permissions.level, 2, "CLI default should be admin (level 2)");
942        assert_eq!(ctx.permissions.max_tier, "elite");
943        assert!(ctx.permissions.tool_access.contains(&"*".to_string()));
944        assert_eq!(ctx.permissions.rate_limit, 0, "CLI admin should have no rate limit");
945        assert!(ctx.permissions.streaming_allowed);
946        assert!(ctx.permissions.escalation_allowed);
947        assert!(ctx.permissions.model_override);
948    }
949}