Skip to main content

llmtrace_core/
lib.rs

1//! Core types, traits, and errors for LLMTrace
2//!
3//! This crate contains foundational types and traits shared across all LLMTrace components.
4//! It provides the core data structures for representing traces, security findings, and
5//! storage/analysis interfaces.
6
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10use std::sync::Arc;
11use std::time::Duration;
12use utoipa::ToSchema;
13use uuid::Uuid;
14
15// ---------------------------------------------------------------------------
16// Identity types
17// ---------------------------------------------------------------------------
18
19/// Unique identifier for a tenant.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, ToSchema)]
21#[schema(value_type = String, format = "uuid")]
22pub struct TenantId(pub Uuid);
23
24impl std::fmt::Display for TenantId {
25    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
26        write!(f, "{}", self.0)
27    }
28}
29
30impl TenantId {
31    /// Create a new random tenant ID.
32    pub fn new() -> Self {
33        Self(Uuid::new_v4())
34    }
35}
36
37impl Default for TenantId {
38    fn default() -> Self {
39        Self::new()
40    }
41}
42
43// ---------------------------------------------------------------------------
44// RBAC & Auth types
45// ---------------------------------------------------------------------------
46
47/// Role for API key-based access control.
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, ToSchema)]
49#[serde(rename_all = "lowercase")]
50pub enum ApiKeyRole {
51    /// Full access: manage tenants, keys, read/write traces.
52    Admin,
53    /// Read + write traces, report actions; no tenant/key management.
54    Operator,
55    /// Read-only access to traces, spans, stats.
56    Viewer,
57}
58
59impl ApiKeyRole {
60    /// Check whether this role is at least as privileged as `required`.
61    #[must_use]
62    pub fn has_permission(self, required: ApiKeyRole) -> bool {
63        self.privilege_level() >= required.privilege_level()
64    }
65
66    /// Numeric privilege level (higher = more privileged).
67    fn privilege_level(self) -> u8 {
68        match self {
69            Self::Admin => 2,
70            Self::Operator => 1,
71            Self::Viewer => 0,
72        }
73    }
74}
75
76impl std::fmt::Display for ApiKeyRole {
77    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
78        match self {
79            Self::Admin => write!(f, "admin"),
80            Self::Operator => write!(f, "operator"),
81            Self::Viewer => write!(f, "viewer"),
82        }
83    }
84}
85
86impl std::str::FromStr for ApiKeyRole {
87    type Err = String;
88
89    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
90        match s.to_lowercase().as_str() {
91            "admin" => Ok(Self::Admin),
92            "operator" => Ok(Self::Operator),
93            "viewer" => Ok(Self::Viewer),
94            _ => Err(format!("unknown role: {s}")),
95        }
96    }
97}
98
99/// A stored API key record (the plaintext key is never persisted).
100#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
101pub struct ApiKeyRecord {
102    /// Unique identifier for this key.
103    #[schema(value_type = String, format = "uuid")]
104    pub id: Uuid,
105    /// Tenant this key belongs to.
106    pub tenant_id: TenantId,
107    /// Human-readable name / label for the key.
108    pub name: String,
109    /// SHA-256 hex digest of the plaintext key.
110    #[serde(skip_serializing)]
111    pub key_hash: String,
112    /// Key prefix for identification (e.g. `llmt_ab12...`).
113    pub key_prefix: String,
114    /// Role granted by this key.
115    pub role: ApiKeyRole,
116    /// When the key was created.
117    #[schema(value_type = String, format = "date-time")]
118    pub created_at: DateTime<Utc>,
119    /// When the key was revoked (`None` if still active).
120    #[schema(value_type = String, format = "date-time")]
121    pub revoked_at: Option<DateTime<Utc>>,
122}
123
124/// Authenticated context injected by the auth middleware.
125#[derive(Debug, Clone)]
126pub struct AuthContext {
127    /// Tenant the caller is authenticated as.
128    pub tenant_id: TenantId,
129    /// Role granted by the API key (or admin bootstrap key).
130    pub role: ApiKeyRole,
131    /// ID of the API key used (`None` for bootstrap admin key).
132    pub key_id: Option<Uuid>,
133}
134
135// ---------------------------------------------------------------------------
136// Security types
137// ---------------------------------------------------------------------------
138
139/// Metadata key for ensemble voting result ("majority" or "single_detector").
140pub const VOTING_RESULT_KEY: &str = "voting_result";
141/// Value indicating a finding was confirmed by multiple detectors.
142pub const VOTING_MAJORITY: &str = "majority";
143/// Value indicating a finding came from a single detector only.
144pub const VOTING_SINGLE_DETECTOR: &str = "single_detector";
145
146/// Returns `true` if a finding type is auxiliary (informational, not a direct
147/// injection/attack indicator). Auxiliary findings should not drive the
148/// security score above the flagging threshold on their own.
149#[must_use]
150pub fn is_auxiliary_finding_type(finding_type: &str) -> bool {
151    matches!(
152        finding_type,
153        "is_hypothetical"
154            | "is_incentive"
155            | "is_urgent"
156            | "is_systemic"
157            | "is_covert"
158            | "is_immoral"
159            | "is_repeated_token"
160            | "context_flooding"
161    )
162}
163
164/// Pre-defined operating points that balance precision against recall.
165///
166/// Used in [`SecurityAnalysisConfig`] and mapped to per-category thresholds
167/// by the ensemble analyzer.
168#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
169#[serde(rename_all = "snake_case")]
170pub enum OperatingPoint {
171    /// High recall, more false positives -- catch everything.
172    HighRecall,
173    /// Balanced precision and recall -- recommended default.
174    #[default]
175    Balanced,
176    /// High precision, fewer false positives -- production-safe.
177    HighPrecision,
178}
179
180/// Severity level for security findings.
181#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, ToSchema)]
182pub enum SecuritySeverity {
183    /// Informational — no immediate action needed.
184    Info,
185    /// Low severity — minor issue.
186    Low,
187    /// Medium severity — should be addressed.
188    Medium,
189    /// High severity — prompt attention needed.
190    High,
191    /// Most severe — immediate attention required.
192    Critical,
193}
194
195impl std::fmt::Display for SecuritySeverity {
196    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
197        match self {
198            Self::Info => write!(f, "Info"),
199            Self::Low => write!(f, "Low"),
200            Self::Medium => write!(f, "Medium"),
201            Self::High => write!(f, "High"),
202            Self::Critical => write!(f, "Critical"),
203        }
204    }
205}
206
207impl std::str::FromStr for SecuritySeverity {
208    type Err = String;
209
210    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
211        match s {
212            "Info" | "info" | "INFO" => Ok(Self::Info),
213            "Low" | "low" | "LOW" => Ok(Self::Low),
214            "Medium" | "medium" | "MEDIUM" => Ok(Self::Medium),
215            "High" | "high" | "HIGH" => Ok(Self::High),
216            "Critical" | "critical" | "CRITICAL" => Ok(Self::Critical),
217            _ => Err(format!("unknown severity: {s}")),
218        }
219    }
220}
221
222/// A security finding detected during analysis.
223#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
224pub struct SecurityFinding {
225    /// Unique identifier for this finding.
226    #[schema(value_type = String, format = "uuid")]
227    pub id: Uuid,
228    /// Severity level of the finding.
229    pub severity: SecuritySeverity,
230    /// Type of security issue (e.g., "prompt_injection", "pii_leak", "cost_anomaly").
231    pub finding_type: String,
232    /// Human-readable description of the finding.
233    pub description: String,
234    /// When the finding was detected.
235    #[schema(value_type = String, format = "date-time")]
236    pub detected_at: DateTime<Utc>,
237    /// Confidence score (0.0 to 1.0).
238    pub confidence_score: f64,
239    /// Location where the issue was found (e.g., "request.messages[0]", "response.content").
240    pub location: Option<String>,
241    /// Additional metadata about the finding.
242    #[schema(value_type = Object)]
243    pub metadata: HashMap<String, String>,
244    /// Whether this finding requires immediate alerting.
245    pub requires_alert: bool,
246}
247
248impl SecurityFinding {
249    /// Create a new security finding.
250    pub fn new(
251        severity: SecuritySeverity,
252        finding_type: String,
253        description: String,
254        confidence_score: f64,
255    ) -> Self {
256        let requires_alert = matches!(
257            severity,
258            SecuritySeverity::Critical | SecuritySeverity::High
259        );
260        Self {
261            id: Uuid::new_v4(),
262            severity,
263            finding_type,
264            description,
265            detected_at: Utc::now(),
266            confidence_score,
267            location: None,
268            metadata: HashMap::new(),
269            requires_alert,
270        }
271    }
272
273    /// Set the location where this finding was detected.
274    pub fn with_location(mut self, location: String) -> Self {
275        self.location = Some(location);
276        self
277    }
278
279    /// Add metadata to the finding.
280    pub fn with_metadata(mut self, key: String, value: String) -> Self {
281        self.metadata.insert(key, value);
282        self
283    }
284
285    /// Set whether this finding requires immediate alerting.
286    pub fn with_alert_required(mut self, requires_alert: bool) -> Self {
287        self.requires_alert = requires_alert;
288        self
289    }
290}
291
292// ---------------------------------------------------------------------------
293// LLM provider types
294// ---------------------------------------------------------------------------
295
296/// Supported LLM providers.
297#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
298pub enum LLMProvider {
299    OpenAI,
300    Anthropic,
301    VLLm,
302    SGLang,
303    TGI,
304    Ollama,
305    AzureOpenAI,
306    Bedrock,
307    Custom(String),
308}
309
310// ---------------------------------------------------------------------------
311// Trace & Span types
312// ---------------------------------------------------------------------------
313
314/// A single span within a trace representing a portion of an LLM interaction.
315#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
316pub struct TraceSpan {
317    /// Unique identifier for the trace this span belongs to.
318    #[schema(value_type = String, format = "uuid")]
319    pub trace_id: Uuid,
320    /// Unique identifier for this span.
321    #[schema(value_type = String, format = "uuid")]
322    pub span_id: Uuid,
323    /// Parent span ID if this is a child span.
324    #[schema(value_type = String, format = "uuid")]
325    pub parent_span_id: Option<Uuid>,
326    /// Tenant this span belongs to.
327    pub tenant_id: TenantId,
328    /// Name of the operation (e.g., "chat_completion", "embedding", "prompt_analysis").
329    pub operation_name: String,
330    /// When the span started.
331    #[schema(value_type = String, format = "date-time")]
332    pub start_time: DateTime<Utc>,
333    /// When the span ended (None if still in progress).
334    #[schema(value_type = String, format = "date-time")]
335    pub end_time: Option<DateTime<Utc>>,
336    /// LLM provider used for this span.
337    pub provider: LLMProvider,
338    /// Model name/identifier.
339    pub model_name: String,
340    /// The input prompt or messages.
341    pub prompt: String,
342    /// The response from the LLM (None if not yet completed).
343    pub response: Option<String>,
344    /// Number of tokens in the prompt.
345    pub prompt_tokens: Option<u32>,
346    /// Number of tokens in the completion/response.
347    pub completion_tokens: Option<u32>,
348    /// Total token count (prompt + completion).
349    pub total_tokens: Option<u32>,
350    /// Time to first token (TTFT) in milliseconds.
351    pub time_to_first_token_ms: Option<u64>,
352    /// Processing duration in milliseconds.
353    pub duration_ms: Option<u64>,
354    /// HTTP status code of the response.
355    pub status_code: Option<u16>,
356    /// Error message if the request failed.
357    pub error_message: Option<String>,
358    /// Estimated cost of this request in USD.
359    pub estimated_cost_usd: Option<f64>,
360    /// Overall security score (0-100, higher = more suspicious).
361    pub security_score: Option<u8>,
362    /// Security findings detected for this span.
363    pub security_findings: Vec<SecurityFinding>,
364    /// Custom tags and metadata.
365    pub tags: HashMap<String, String>,
366    /// Events that occurred during this span (e.g., streaming chunks, analysis results).
367    pub events: Vec<SpanEvent>,
368    /// Agent actions captured during this span (tool calls, commands, web access, etc.).
369    #[serde(default)]
370    pub agent_actions: Vec<AgentAction>,
371}
372
373impl TraceSpan {
374    /// Create a new trace span.
375    pub fn new(
376        trace_id: Uuid,
377        tenant_id: TenantId,
378        operation_name: String,
379        provider: LLMProvider,
380        model_name: String,
381        prompt: String,
382    ) -> Self {
383        Self {
384            trace_id,
385            span_id: Uuid::new_v4(),
386            parent_span_id: None,
387            tenant_id,
388            operation_name,
389            start_time: Utc::now(),
390            end_time: None,
391            provider,
392            model_name,
393            prompt,
394            response: None,
395            prompt_tokens: None,
396            completion_tokens: None,
397            total_tokens: None,
398            time_to_first_token_ms: None,
399            duration_ms: None,
400            status_code: None,
401            error_message: None,
402            estimated_cost_usd: None,
403            security_score: None,
404            security_findings: Vec::new(),
405            tags: HashMap::new(),
406            events: Vec::new(),
407            agent_actions: Vec::new(),
408        }
409    }
410
411    /// Finish the span with a response.
412    pub fn finish_with_response(mut self, response: String) -> Self {
413        self.response = Some(response);
414        let end_time = Utc::now();
415        self.end_time = Some(end_time);
416        self.duration_ms =
417            Some((end_time.signed_duration_since(self.start_time)).num_milliseconds() as u64);
418        self
419    }
420
421    /// Mark the span as failed with an error.
422    pub fn finish_with_error(mut self, error: String, status_code: Option<u16>) -> Self {
423        self.error_message = Some(error);
424        self.status_code = status_code;
425        let end_time = Utc::now();
426        self.end_time = Some(end_time);
427        self.duration_ms =
428            Some((end_time.signed_duration_since(self.start_time)).num_milliseconds() as u64);
429        self
430    }
431
432    /// Add a security finding to this span.
433    pub fn add_security_finding(&mut self, finding: SecurityFinding) {
434        self.security_findings.push(finding);
435        let max_score = self
436            .security_findings
437            .iter()
438            .map(|f| {
439                let base = match f.severity {
440                    SecuritySeverity::Critical => 95,
441                    SecuritySeverity::High => 80,
442                    SecuritySeverity::Medium => 60,
443                    SecuritySeverity::Low => 30,
444                    SecuritySeverity::Info => 10,
445                };
446                // Auxiliary (non-injection) findings: cap at 30 so they
447                // don't cross the flagging threshold (50) on their own
448                if is_auxiliary_finding_type(&f.finding_type) {
449                    return base.min(30);
450                }
451                // Discount single-detector findings: cap at Medium (60)
452                if f.metadata
453                    .get(VOTING_RESULT_KEY)
454                    .is_some_and(|v| v == VOTING_SINGLE_DETECTOR)
455                {
456                    base.min(60)
457                } else {
458                    base
459                }
460            })
461            .max()
462            .unwrap_or(0);
463        self.security_score = Some(max_score as u8);
464    }
465
466    /// Add an event to this span.
467    pub fn add_event(&mut self, event: SpanEvent) {
468        self.events.push(event);
469    }
470
471    /// Add an agent action to this span.
472    pub fn add_agent_action(&mut self, action: AgentAction) {
473        self.agent_actions.push(action);
474    }
475
476    /// Return all tool call actions in this span.
477    #[must_use]
478    pub fn tool_calls(&self) -> Vec<&AgentAction> {
479        self.agent_actions
480            .iter()
481            .filter(|a| a.action_type == AgentActionType::ToolCall)
482            .collect()
483    }
484
485    /// Return all web access actions in this span.
486    #[must_use]
487    pub fn web_accesses(&self) -> Vec<&AgentAction> {
488        self.agent_actions
489            .iter()
490            .filter(|a| a.action_type == AgentActionType::WebAccess)
491            .collect()
492    }
493
494    /// Return all command execution actions in this span.
495    #[must_use]
496    pub fn commands(&self) -> Vec<&AgentAction> {
497        self.agent_actions
498            .iter()
499            .filter(|a| a.action_type == AgentActionType::CommandExecution)
500            .collect()
501    }
502
503    /// Check if this span has any tool call actions.
504    #[must_use]
505    pub fn has_tool_calls(&self) -> bool {
506        self.agent_actions
507            .iter()
508            .any(|a| a.action_type == AgentActionType::ToolCall)
509    }
510
511    /// Check if this span has any web access actions.
512    #[must_use]
513    pub fn has_web_access(&self) -> bool {
514        self.agent_actions
515            .iter()
516            .any(|a| a.action_type == AgentActionType::WebAccess)
517    }
518
519    /// Check if this span has any command execution actions.
520    #[must_use]
521    pub fn has_commands(&self) -> bool {
522        self.agent_actions
523            .iter()
524            .any(|a| a.action_type == AgentActionType::CommandExecution)
525    }
526
527    /// Get the duration of this span.
528    pub fn duration(&self) -> Option<Duration> {
529        self.end_time.map(|end_time| {
530            Duration::from_millis(
531                end_time
532                    .signed_duration_since(self.start_time)
533                    .num_milliseconds() as u64,
534            )
535        })
536    }
537
538    /// Check if the span is complete.
539    pub fn is_complete(&self) -> bool {
540        self.end_time.is_some()
541    }
542
543    /// Check if the span failed.
544    pub fn is_failed(&self) -> bool {
545        self.error_message.is_some()
546    }
547}
548
549// ---------------------------------------------------------------------------
550// Agent action types
551// ---------------------------------------------------------------------------
552
553/// Maximum size in bytes for captured action results (stdout, response bodies, etc.).
554pub const AGENT_ACTION_RESULT_MAX_BYTES: usize = 4096;
555
556/// Type of agent action observed during an LLM interaction.
557#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, ToSchema)]
558#[serde(rename_all = "snake_case")]
559pub enum AgentActionType {
560    /// LLM tool/function call (e.g. OpenAI `tool_calls`, Anthropic `tool_use`).
561    ToolCall,
562    /// Agent skill or plugin invocation.
563    SkillInvocation,
564    /// Shell command or subprocess execution.
565    CommandExecution,
566    /// HTTP request, curl, fetch, or browser action.
567    WebAccess,
568    /// File read, write, or delete operation.
569    FileAccess,
570}
571
572impl std::fmt::Display for AgentActionType {
573    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
574        match self {
575            Self::ToolCall => write!(f, "tool_call"),
576            Self::SkillInvocation => write!(f, "skill_invocation"),
577            Self::CommandExecution => write!(f, "command_execution"),
578            Self::WebAccess => write!(f, "web_access"),
579            Self::FileAccess => write!(f, "file_access"),
580        }
581    }
582}
583
584/// A single agent action captured during or after an LLM interaction.
585#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
586pub struct AgentAction {
587    /// Unique identifier for this action.
588    #[schema(value_type = String, format = "uuid")]
589    pub id: Uuid,
590    /// Type of action.
591    pub action_type: AgentActionType,
592    /// Name of the tool, skill, command, URL, or file path.
593    pub name: String,
594    /// Arguments or parameters (JSON-encoded for tool calls, raw string otherwise).
595    #[serde(default)]
596    pub arguments: Option<String>,
597    /// Result or output of the action (truncated to [`AGENT_ACTION_RESULT_MAX_BYTES`]).
598    #[serde(default)]
599    pub result: Option<String>,
600    /// Duration of the action in milliseconds.
601    #[serde(default)]
602    pub duration_ms: Option<u64>,
603    /// Whether the action succeeded.
604    #[serde(default = "default_true")]
605    pub success: bool,
606    /// Exit code (for command executions).
607    #[serde(default)]
608    pub exit_code: Option<i32>,
609    /// HTTP method (for web access).
610    #[serde(default)]
611    pub http_method: Option<String>,
612    /// HTTP status code (for web access).
613    #[serde(default)]
614    pub http_status: Option<u16>,
615    /// File operation type: "read", "write", "delete" (for file access).
616    #[serde(default)]
617    pub file_operation: Option<String>,
618    /// When the action occurred.
619    #[schema(value_type = String, format = "date-time")]
620    pub timestamp: DateTime<Utc>,
621    /// Additional metadata.
622    #[schema(value_type = Object)]
623    pub metadata: HashMap<String, String>,
624}
625
626fn default_true() -> bool {
627    true
628}
629
630impl AgentAction {
631    /// Create a new agent action.
632    pub fn new(action_type: AgentActionType, name: String) -> Self {
633        Self {
634            id: Uuid::new_v4(),
635            action_type,
636            name,
637            arguments: None,
638            result: None,
639            duration_ms: None,
640            success: true,
641            exit_code: None,
642            http_method: None,
643            http_status: None,
644            file_operation: None,
645            timestamp: Utc::now(),
646            metadata: HashMap::new(),
647        }
648    }
649
650    /// Set the arguments for this action.
651    pub fn with_arguments(mut self, arguments: String) -> Self {
652        self.arguments = Some(arguments);
653        self
654    }
655
656    /// Set the result, truncating to [`AGENT_ACTION_RESULT_MAX_BYTES`].
657    pub fn with_result(mut self, result: String) -> Self {
658        if result.len() > AGENT_ACTION_RESULT_MAX_BYTES {
659            self.result = Some(result[..AGENT_ACTION_RESULT_MAX_BYTES].to_string());
660        } else {
661            self.result = Some(result);
662        }
663        self
664    }
665
666    /// Set the duration in milliseconds.
667    pub fn with_duration_ms(mut self, ms: u64) -> Self {
668        self.duration_ms = Some(ms);
669        self
670    }
671
672    /// Mark the action as failed.
673    pub fn with_failure(mut self) -> Self {
674        self.success = false;
675        self
676    }
677
678    /// Set the exit code (for command executions).
679    pub fn with_exit_code(mut self, code: i32) -> Self {
680        self.exit_code = Some(code);
681        self
682    }
683
684    /// Set HTTP method and status (for web access).
685    pub fn with_http(mut self, method: String, status: u16) -> Self {
686        self.http_method = Some(method);
687        self.http_status = Some(status);
688        self
689    }
690
691    /// Set the file operation type (for file access).
692    pub fn with_file_operation(mut self, op: String) -> Self {
693        self.file_operation = Some(op);
694        self
695    }
696
697    /// Add metadata to this action.
698    pub fn with_metadata(mut self, key: String, value: String) -> Self {
699        self.metadata.insert(key, value);
700        self
701    }
702}
703
704/// An event that occurred during a span.
705#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
706pub struct SpanEvent {
707    /// Unique identifier for the event.
708    #[schema(value_type = String, format = "uuid")]
709    pub id: Uuid,
710    /// When the event occurred.
711    #[schema(value_type = String, format = "date-time")]
712    pub timestamp: DateTime<Utc>,
713    /// Type of event (e.g., "token_received", "security_scan_completed", "error_occurred").
714    pub event_type: String,
715    /// Human-readable description.
716    pub description: String,
717    /// Event-specific data.
718    #[schema(value_type = Object)]
719    pub data: HashMap<String, String>,
720}
721
722impl SpanEvent {
723    /// Create a new span event.
724    pub fn new(event_type: String, description: String) -> Self {
725        Self {
726            id: Uuid::new_v4(),
727            timestamp: Utc::now(),
728            event_type,
729            description,
730            data: HashMap::new(),
731        }
732    }
733
734    /// Add data to the event.
735    pub fn with_data(mut self, key: String, value: String) -> Self {
736        self.data.insert(key, value);
737        self
738    }
739}
740
741/// A complete trace event representing an LLM interaction.
742#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
743pub struct TraceEvent {
744    /// Unique trace identifier.
745    #[schema(value_type = String, format = "uuid")]
746    pub trace_id: Uuid,
747    /// Tenant that owns this trace.
748    pub tenant_id: TenantId,
749    /// Spans within this trace.
750    pub spans: Vec<TraceSpan>,
751    /// When the trace was created.
752    #[schema(value_type = String, format = "date-time")]
753    pub created_at: DateTime<Utc>,
754}
755
756// ---------------------------------------------------------------------------
757// Metadata types (tenants, configs, audit)
758// ---------------------------------------------------------------------------
759
760/// A tenant in the system.
761#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
762pub struct Tenant {
763    /// Unique tenant identifier.
764    pub id: TenantId,
765    /// Human-readable tenant name.
766    pub name: String,
767    /// Unique API token for proxy traffic.
768    pub api_token: String,
769    /// Subscription plan (e.g., "free", "pro", "enterprise").
770    pub plan: String,
771    /// When the tenant was created.
772    #[schema(value_type = String, format = "date-time")]
773    pub created_at: DateTime<Utc>,
774    /// Arbitrary tenant-level configuration.
775    #[schema(value_type = Object)]
776    pub config: serde_json::Value,
777}
778
779/// Per-tenant configuration for security thresholds and feature flags.
780#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
781pub struct TenantConfig {
782    /// Tenant this configuration belongs to.
783    pub tenant_id: TenantId,
784    /// Security severity thresholds (e.g., "alert_min_score" → 80.0).
785    #[schema(value_type = Object)]
786    pub security_thresholds: HashMap<String, f64>,
787    /// Feature flags (e.g., "enable_pii_detection" → true).
788    #[schema(value_type = Object)]
789    pub feature_flags: HashMap<String, bool>,
790    /// Monitoring scope for this tenant.
791    pub monitoring_scope: MonitoringScope,
792    /// Rate limit in requests per minute (optional).
793    pub rate_limit_rpm: Option<u32>,
794    /// Monthly budget in USD (optional).
795    pub monthly_budget: Option<f64>,
796}
797
798/// Controls which parts of an LLM interaction are stored/queried for a tenant.
799#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
800#[serde(rename_all = "snake_case")]
801pub enum MonitoringScope {
802    /// Store both prompt and response (default).
803    #[default]
804    Hybrid,
805    /// Store prompt only (do not store response).
806    InputOnly,
807    /// Store response only (do not store prompt).
808    OutputOnly,
809}
810
811impl std::fmt::Display for MonitoringScope {
812    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
813        let s = match self {
814            Self::Hybrid => "hybrid",
815            Self::InputOnly => "input_only",
816            Self::OutputOnly => "output_only",
817        };
818        f.write_str(s)
819    }
820}
821
822impl std::str::FromStr for MonitoringScope {
823    type Err = String;
824
825    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
826        match s.trim().to_ascii_lowercase().as_str() {
827            "hybrid" => Ok(Self::Hybrid),
828            "input_only" => Ok(Self::InputOnly),
829            "output_only" => Ok(Self::OutputOnly),
830            other => Err(format!("Unknown monitoring scope: {other}")),
831        }
832    }
833}
834
835/// An audit log entry recording a tenant-scoped action.
836#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
837pub struct AuditEvent {
838    /// Unique event identifier.
839    #[schema(value_type = String, format = "uuid")]
840    pub id: Uuid,
841    /// Tenant this event belongs to.
842    pub tenant_id: TenantId,
843    /// Type of event (e.g., "config_changed", "tenant_created").
844    pub event_type: String,
845    /// Who performed the action.
846    pub actor: String,
847    /// What resource was affected.
848    pub resource: String,
849    /// Arbitrary event data.
850    #[schema(value_type = Object)]
851    pub data: serde_json::Value,
852    /// When the event occurred.
853    #[schema(value_type = String, format = "date-time")]
854    pub timestamp: DateTime<Utc>,
855}
856
857/// A stored compliance report record for persistence.
858///
859/// Contains all data needed to persist and retrieve a compliance report
860/// from the metadata repository.
861#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
862pub struct ComplianceReportRecord {
863    /// Unique report identifier.
864    #[schema(value_type = String, format = "uuid")]
865    pub id: Uuid,
866    /// Tenant that requested the report.
867    pub tenant_id: TenantId,
868    /// Type of compliance report (e.g. "soc2", "gdpr", "hipaa").
869    pub report_type: String,
870    /// Current status: "pending", "completed", or "failed".
871    pub status: String,
872    /// Start of the reporting period.
873    #[schema(value_type = String, format = "date-time")]
874    pub period_start: DateTime<Utc>,
875    /// End of the reporting period.
876    #[schema(value_type = String, format = "date-time")]
877    pub period_end: DateTime<Utc>,
878    /// When the report was requested.
879    #[schema(value_type = String, format = "date-time")]
880    pub created_at: DateTime<Utc>,
881    /// When the report generation completed (if finished).
882    #[schema(value_type = String, format = "date-time")]
883    pub completed_at: Option<DateTime<Utc>>,
884    /// Report content as JSON (populated when status is "completed").
885    #[schema(value_type = Object)]
886    pub content: Option<serde_json::Value>,
887    /// Error message (populated when status is "failed").
888    pub error: Option<String>,
889}
890
891/// Query parameters for listing compliance reports.
892#[derive(Debug, Clone, ToSchema)]
893pub struct ReportQuery {
894    /// Tenant to query reports for.
895    pub tenant_id: TenantId,
896    /// Maximum number of results.
897    pub limit: Option<u32>,
898    /// Number of results to skip (for pagination).
899    pub offset: Option<u32>,
900}
901
902impl ReportQuery {
903    /// Create a new report query for a tenant.
904    pub fn new(tenant_id: TenantId) -> Self {
905        Self {
906            tenant_id,
907            limit: None,
908            offset: None,
909        }
910    }
911
912    /// Set the result limit.
913    pub fn with_limit(mut self, limit: u32) -> Self {
914        self.limit = Some(limit);
915        self
916    }
917
918    /// Set the offset for pagination.
919    pub fn with_offset(mut self, offset: u32) -> Self {
920        self.offset = Some(offset);
921        self
922    }
923}
924
925/// Query parameters for filtering audit events.
926#[derive(Debug, Clone, ToSchema)]
927pub struct AuditQuery {
928    /// Tenant to query audit events for.
929    pub tenant_id: TenantId,
930    /// Filter by event type.
931    pub event_type: Option<String>,
932    /// Start time for the query range.
933    #[schema(value_type = String, format = "date-time")]
934    pub start_time: Option<DateTime<Utc>>,
935    /// End time for the query range.
936    #[schema(value_type = String, format = "date-time")]
937    pub end_time: Option<DateTime<Utc>>,
938    /// Maximum number of results.
939    pub limit: Option<u32>,
940    /// Number of results to skip (for pagination).
941    pub offset: Option<u32>,
942}
943
944impl AuditQuery {
945    /// Create a new audit query for a tenant.
946    pub fn new(tenant_id: TenantId) -> Self {
947        Self {
948            tenant_id,
949            event_type: None,
950            start_time: None,
951            end_time: None,
952            limit: None,
953            offset: None,
954        }
955    }
956
957    /// Filter by event type.
958    pub fn with_event_type(mut self, event_type: String) -> Self {
959        self.event_type = Some(event_type);
960        self
961    }
962
963    /// Set a time range filter.
964    pub fn with_time_range(mut self, start: DateTime<Utc>, end: DateTime<Utc>) -> Self {
965        self.start_time = Some(start);
966        self.end_time = Some(end);
967        self
968    }
969
970    /// Set the result limit.
971    pub fn with_limit(mut self, limit: u32) -> Self {
972        self.limit = Some(limit);
973        self
974    }
975}
976
977// ---------------------------------------------------------------------------
978// Configuration types
979// ---------------------------------------------------------------------------
980
981/// Configuration for the transparent proxy.
982#[derive(Debug, Clone, Serialize, Deserialize)]
983pub struct ProxyConfig {
984    /// Address and port to bind the proxy server to.
985    pub listen_addr: String,
986    /// Upstream URL for the LLM provider.
987    pub upstream_url: String,
988    /// Storage configuration section.
989    #[serde(default)]
990    pub storage: StorageConfig,
991    /// Request timeout in milliseconds.
992    pub timeout_ms: u64,
993    /// Connection timeout in milliseconds.
994    pub connection_timeout_ms: u64,
995    /// Maximum number of concurrent connections.
996    pub max_connections: u32,
997    /// Enable TLS for the proxy server.
998    pub enable_tls: bool,
999    /// TLS certificate file path.
1000    pub tls_cert_file: Option<String>,
1001    /// TLS private key file path.
1002    pub tls_key_file: Option<String>,
1003    /// Enable security analysis of requests/responses.
1004    pub enable_security_analysis: bool,
1005    /// Enable trace storage.
1006    pub enable_trace_storage: bool,
1007    /// Enable streaming support.
1008    pub enable_streaming: bool,
1009    /// Maximum request body size in bytes.
1010    pub max_request_size_bytes: u64,
1011    /// Security analysis timeout in milliseconds.
1012    pub security_analysis_timeout_ms: u64,
1013    /// Trace storage timeout in milliseconds.
1014    pub trace_storage_timeout_ms: u64,
1015    /// Rate limiting configuration.
1016    pub rate_limiting: RateLimitConfig,
1017    /// Circuit breaker configuration.
1018    pub circuit_breaker: CircuitBreakerConfig,
1019    /// Health check configuration.
1020    pub health_check: HealthCheckConfig,
1021    /// Logging configuration.
1022    #[serde(default)]
1023    pub logging: LoggingConfig,
1024    /// Cost estimation configuration.
1025    #[serde(default)]
1026    pub cost_estimation: CostEstimationConfig,
1027    /// Alert engine configuration for webhook notifications.
1028    #[serde(default)]
1029    pub alerts: AlertConfig,
1030    /// Cost cap configuration for budget and token enforcement.
1031    #[serde(default)]
1032    pub cost_caps: CostCapConfig,
1033    /// Security analysis configuration (ML-based detection, thresholds).
1034    #[serde(default)]
1035    pub security_analysis: SecurityAnalysisConfig,
1036    /// Security enforcement configuration (pre-request blocking/flagging).
1037    #[serde(default)]
1038    pub enforcement: EnforcementConfig,
1039    /// OpenTelemetry OTLP ingestion configuration.
1040    #[serde(default)]
1041    pub otel_ingest: OtelIngestConfig,
1042    /// Authentication and RBAC configuration.
1043    #[serde(default)]
1044    pub auth: AuthConfig,
1045    /// gRPC ingestion gateway configuration.
1046    #[serde(default)]
1047    pub grpc: GrpcConfig,
1048    /// Anomaly detection configuration.
1049    #[serde(default)]
1050    pub anomaly_detection: AnomalyDetectionConfig,
1051    /// Streaming security analysis configuration.
1052    #[serde(default)]
1053    pub streaming_analysis: StreamingAnalysisConfig,
1054    /// PII detection and redaction configuration.
1055    #[serde(default)]
1056    pub pii: PiiConfig,
1057    /// Output safety configuration (toxicity detection, output analysis).
1058    #[serde(default)]
1059    pub output_safety: OutputSafetyConfig,
1060    /// Graceful shutdown configuration.
1061    #[serde(default)]
1062    pub shutdown: ShutdownConfig,
1063}
1064
1065impl Default for ProxyConfig {
1066    fn default() -> Self {
1067        Self {
1068            listen_addr: "0.0.0.0:8080".to_string(),
1069            upstream_url: "https://api.openai.com".to_string(),
1070            storage: StorageConfig::default(),
1071            timeout_ms: 30000,
1072            connection_timeout_ms: 5000,
1073            max_connections: 1000,
1074            enable_tls: false,
1075            tls_cert_file: None,
1076            tls_key_file: None,
1077            enable_security_analysis: true,
1078            enable_trace_storage: true,
1079            enable_streaming: true,
1080            max_request_size_bytes: 50 * 1024 * 1024, // 50MB
1081            security_analysis_timeout_ms: 5000,
1082            trace_storage_timeout_ms: 10000,
1083            rate_limiting: RateLimitConfig::default(),
1084            circuit_breaker: CircuitBreakerConfig::default(),
1085            health_check: HealthCheckConfig::default(),
1086            logging: LoggingConfig::default(),
1087            cost_estimation: CostEstimationConfig::default(),
1088            alerts: AlertConfig::default(),
1089            cost_caps: CostCapConfig::default(),
1090            security_analysis: SecurityAnalysisConfig::default(),
1091            enforcement: EnforcementConfig::default(),
1092            otel_ingest: OtelIngestConfig::default(),
1093            auth: AuthConfig::default(),
1094            grpc: GrpcConfig::default(),
1095            anomaly_detection: AnomalyDetectionConfig::default(),
1096            streaming_analysis: StreamingAnalysisConfig::default(),
1097            pii: PiiConfig::default(),
1098            output_safety: OutputSafetyConfig::default(),
1099            shutdown: ShutdownConfig::default(),
1100        }
1101    }
1102}
1103
1104/// Storage configuration section within [`ProxyConfig`].
1105#[derive(Debug, Clone, Serialize, Deserialize)]
1106pub struct StorageConfig {
1107    /// Storage profile: `"lite"` (SQLite), `"memory"` (in-memory), or `"production"` (ClickHouse + PostgreSQL + Redis).
1108    #[serde(default = "default_storage_profile")]
1109    pub profile: String,
1110    /// Database file path (used by the `"lite"` profile).
1111    #[serde(default = "default_database_path")]
1112    pub database_path: String,
1113    /// ClickHouse HTTP URL (used by the `"production"` profile).
1114    #[serde(default)]
1115    pub clickhouse_url: Option<String>,
1116    /// ClickHouse database name (used by the `"production"` profile).
1117    #[serde(default)]
1118    pub clickhouse_database: Option<String>,
1119    /// PostgreSQL connection URL (used by the `"production"` profile).
1120    #[serde(default)]
1121    pub postgres_url: Option<String>,
1122    /// Redis connection URL (used by the `"production"` profile).
1123    #[serde(default)]
1124    pub redis_url: Option<String>,
1125    /// Automatically run pending database migrations on startup.
1126    ///
1127    /// Defaults to `true` for development (lite/memory profiles). Set to
1128    /// `false` in production to require explicit `llmtrace-proxy migrate`.
1129    #[serde(default = "default_auto_migrate")]
1130    pub auto_migrate: bool,
1131}
1132
1133fn default_storage_profile() -> String {
1134    "lite".to_string()
1135}
1136
1137fn default_database_path() -> String {
1138    "llmtrace.db".to_string()
1139}
1140
1141fn default_auto_migrate() -> bool {
1142    true
1143}
1144
1145impl Default for StorageConfig {
1146    fn default() -> Self {
1147        Self {
1148            profile: default_storage_profile(),
1149            database_path: default_database_path(),
1150            clickhouse_url: None,
1151            clickhouse_database: None,
1152            postgres_url: None,
1153            redis_url: None,
1154            auto_migrate: default_auto_migrate(),
1155        }
1156    }
1157}
1158
1159/// Per-tenant rate limit override.
1160#[derive(Debug, Clone, Serialize, Deserialize)]
1161pub struct TenantRateLimitOverride {
1162    /// Requests per second for this tenant.
1163    pub requests_per_second: u32,
1164    /// Burst size for this tenant.
1165    pub burst_size: u32,
1166}
1167
1168/// Rate limiting configuration.
1169#[derive(Debug, Clone, Serialize, Deserialize)]
1170pub struct RateLimitConfig {
1171    /// Enable rate limiting.
1172    pub enabled: bool,
1173    /// Default requests per second limit.
1174    pub requests_per_second: u32,
1175    /// Default burst size.
1176    pub burst_size: u32,
1177    /// Rate limiting window in seconds.
1178    pub window_seconds: u32,
1179    /// Per-tenant overrides keyed by tenant UUID string.
1180    #[serde(default)]
1181    pub tenant_overrides: HashMap<String, TenantRateLimitOverride>,
1182}
1183
1184impl Default for RateLimitConfig {
1185    fn default() -> Self {
1186        Self {
1187            enabled: true,
1188            requests_per_second: 100,
1189            burst_size: 200,
1190            window_seconds: 60,
1191            tenant_overrides: HashMap::new(),
1192        }
1193    }
1194}
1195
1196/// Circuit breaker configuration.
1197#[derive(Debug, Clone, Serialize, Deserialize)]
1198pub struct CircuitBreakerConfig {
1199    /// Enable circuit breaker.
1200    pub enabled: bool,
1201    /// Failure threshold before opening circuit.
1202    pub failure_threshold: u32,
1203    /// Recovery timeout in milliseconds.
1204    pub recovery_timeout_ms: u64,
1205    /// Half-open state max calls.
1206    pub half_open_max_calls: u32,
1207}
1208
1209impl Default for CircuitBreakerConfig {
1210    fn default() -> Self {
1211        Self {
1212            enabled: true,
1213            failure_threshold: 10,
1214            recovery_timeout_ms: 30000,
1215            half_open_max_calls: 3,
1216        }
1217    }
1218}
1219
1220/// Health check configuration.
1221#[derive(Debug, Clone, Serialize, Deserialize)]
1222pub struct HealthCheckConfig {
1223    /// Enable health checks.
1224    pub enabled: bool,
1225    /// Health check endpoint path.
1226    pub path: String,
1227    /// Health check interval in seconds.
1228    pub interval_seconds: u32,
1229    /// Health check timeout in milliseconds.
1230    pub timeout_ms: u64,
1231    /// Number of retries before marking unhealthy.
1232    pub retries: u32,
1233}
1234
1235impl Default for HealthCheckConfig {
1236    fn default() -> Self {
1237        Self {
1238            enabled: true,
1239            path: "/health".to_string(),
1240            interval_seconds: 10,
1241            timeout_ms: 5000,
1242            retries: 3,
1243        }
1244    }
1245}
1246
1247/// Cost estimation configuration.
1248#[derive(Debug, Clone, Serialize, Deserialize)]
1249pub struct CostEstimationConfig {
1250    /// Enable cost estimation on traced requests.
1251    #[serde(default = "default_cost_estimation_enabled")]
1252    pub enabled: bool,
1253    /// Optional path to an external pricing YAML/JSON file.
1254    ///
1255    /// When set, the proxy loads model pricing from this file at startup
1256    /// (and reloads on SIGHUP). If the file is missing, built-in defaults
1257    /// are used as a fallback.
1258    #[serde(default)]
1259    pub pricing_file: Option<String>,
1260    /// Custom model pricing overrides (model name → pricing).
1261    #[serde(default)]
1262    pub custom_models: HashMap<String, ModelPricingConfig>,
1263}
1264
1265fn default_cost_estimation_enabled() -> bool {
1266    true
1267}
1268
1269impl Default for CostEstimationConfig {
1270    fn default() -> Self {
1271        Self {
1272            enabled: default_cost_estimation_enabled(),
1273            pricing_file: None,
1274            custom_models: HashMap::new(),
1275        }
1276    }
1277}
1278
1279/// Pricing for a single model (per 1 million tokens).
1280#[derive(Debug, Clone, Serialize, Deserialize)]
1281pub struct ModelPricingConfig {
1282    /// Cost per 1 million input/prompt tokens in USD.
1283    pub input_per_million: f64,
1284    /// Cost per 1 million output/completion tokens in USD.
1285    pub output_per_million: f64,
1286}
1287
1288// ---------------------------------------------------------------------------
1289// Cost cap types
1290// ---------------------------------------------------------------------------
1291
1292/// Time window for budget enforcement.
1293#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, ToSchema)]
1294#[serde(rename_all = "lowercase")]
1295pub enum BudgetWindow {
1296    /// Rolling one-hour window.
1297    Hourly,
1298    /// Rolling one-day (24 h) window.
1299    Daily,
1300    /// Rolling seven-day window.
1301    Weekly,
1302    /// Rolling thirty-day window.
1303    Monthly,
1304}
1305
1306impl BudgetWindow {
1307    /// Duration of this window in seconds.
1308    #[must_use]
1309    pub fn duration_secs(&self) -> u64 {
1310        match self {
1311            Self::Hourly => 3_600,
1312            Self::Daily => 86_400,
1313            Self::Weekly => 604_800,
1314            Self::Monthly => 2_592_000, // 30 days
1315        }
1316    }
1317
1318    /// TTL for cache keys — slightly longer than the window so late
1319    /// writes are not lost.
1320    #[must_use]
1321    pub fn cache_ttl(&self) -> Duration {
1322        Duration::from_secs(self.duration_secs() + 300)
1323    }
1324}
1325
1326impl std::fmt::Display for BudgetWindow {
1327    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1328        match self {
1329            Self::Hourly => write!(f, "hourly"),
1330            Self::Daily => write!(f, "daily"),
1331            Self::Weekly => write!(f, "weekly"),
1332            Self::Monthly => write!(f, "monthly"),
1333        }
1334    }
1335}
1336
1337/// A USD budget cap for a given time window.
1338#[derive(Debug, Clone, Serialize, Deserialize)]
1339pub struct BudgetCap {
1340    /// Which time window this cap applies to.
1341    pub window: BudgetWindow,
1342    /// Hard cap in USD — requests are rejected (429) when exceeded.
1343    pub hard_limit_usd: f64,
1344    /// Optional soft cap in USD — triggers an alert but allows the request.
1345    #[serde(default)]
1346    pub soft_limit_usd: Option<f64>,
1347}
1348
1349/// Per-request token caps.
1350#[derive(Debug, Clone, Serialize, Deserialize)]
1351pub struct TokenCap {
1352    /// Maximum prompt (input) tokens per request. `None` = unlimited.
1353    #[serde(default)]
1354    pub max_prompt_tokens: Option<u32>,
1355    /// Maximum completion (output) tokens per request. `None` = unlimited.
1356    #[serde(default)]
1357    pub max_completion_tokens: Option<u32>,
1358    /// Maximum total tokens per request. `None` = unlimited.
1359    #[serde(default)]
1360    pub max_total_tokens: Option<u32>,
1361}
1362
1363/// Per-agent cost cap override.
1364#[derive(Debug, Clone, Serialize, Deserialize)]
1365pub struct AgentCostCap {
1366    /// Agent identifier (matched against `X-LLMTrace-Agent-ID` header).
1367    pub agent_id: String,
1368    /// Budget caps that override the defaults for this agent.
1369    #[serde(default)]
1370    pub budget_caps: Vec<BudgetCap>,
1371    /// Token caps that override the defaults for this agent.
1372    #[serde(default)]
1373    pub token_cap: Option<TokenCap>,
1374}
1375
1376/// Top-level cost cap configuration section within [`ProxyConfig`].
1377#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1378pub struct CostCapConfig {
1379    /// Enable cost cap enforcement.
1380    #[serde(default)]
1381    pub enabled: bool,
1382    /// Default budget caps applied to all tenants/agents unless overridden.
1383    #[serde(default)]
1384    pub default_budget_caps: Vec<BudgetCap>,
1385    /// Default per-request token caps.
1386    #[serde(default)]
1387    pub default_token_cap: Option<TokenCap>,
1388    /// Per-agent overrides.
1389    #[serde(default)]
1390    pub agents: Vec<AgentCostCap>,
1391}
1392
1393// ---------------------------------------------------------------------------
1394// Anomaly detection types
1395// ---------------------------------------------------------------------------
1396
1397/// Type of anomaly detected.
1398#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
1399#[serde(rename_all = "snake_case")]
1400pub enum AnomalyType {
1401    /// Abnormally high cost for a single request.
1402    CostSpike,
1403    /// Abnormally high token usage for a single request.
1404    TokenSpike,
1405    /// Abnormally high request velocity (requests per minute).
1406    VelocitySpike,
1407    /// Abnormally high response latency.
1408    LatencySpike,
1409}
1410
1411impl std::fmt::Display for AnomalyType {
1412    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1413        match self {
1414            Self::CostSpike => write!(f, "cost_spike"),
1415            Self::TokenSpike => write!(f, "token_spike"),
1416            Self::VelocitySpike => write!(f, "velocity_spike"),
1417            Self::LatencySpike => write!(f, "latency_spike"),
1418        }
1419    }
1420}
1421
1422/// Anomaly detection configuration.
1423///
1424/// When enabled, the proxy tracks per-tenant moving averages and detects
1425/// statistical anomalies using a sliding window and sigma thresholds.
1426///
1427/// # Example (YAML)
1428///
1429/// ```yaml
1430/// anomaly_detection:
1431///   enabled: true
1432///   window_size: 100
1433///   sigma_threshold: 3.0
1434///   check_cost: true
1435///   check_tokens: true
1436///   check_velocity: true
1437///   check_latency: true
1438/// ```
1439#[derive(Debug, Clone, Serialize, Deserialize)]
1440pub struct AnomalyDetectionConfig {
1441    /// Enable anomaly detection.
1442    #[serde(default)]
1443    pub enabled: bool,
1444    /// Number of recent observations in the sliding window.
1445    #[serde(default = "default_anomaly_window_size")]
1446    pub window_size: usize,
1447    /// Sigma multiplier for anomaly threshold (default: 3.0).
1448    #[serde(default = "default_anomaly_sigma_threshold")]
1449    pub sigma_threshold: f64,
1450    /// Check for cost anomalies.
1451    #[serde(default = "default_true_flag")]
1452    pub check_cost: bool,
1453    /// Check for token usage anomalies.
1454    #[serde(default = "default_true_flag")]
1455    pub check_tokens: bool,
1456    /// Check for request velocity anomalies.
1457    #[serde(default = "default_true_flag")]
1458    pub check_velocity: bool,
1459    /// Check for latency anomalies.
1460    #[serde(default = "default_true_flag")]
1461    pub check_latency: bool,
1462}
1463
1464fn default_anomaly_window_size() -> usize {
1465    100
1466}
1467
1468fn default_anomaly_sigma_threshold() -> f64 {
1469    3.0
1470}
1471
1472fn default_true_flag() -> bool {
1473    true
1474}
1475
1476impl Default for AnomalyDetectionConfig {
1477    fn default() -> Self {
1478        Self {
1479            enabled: false,
1480            window_size: default_anomaly_window_size(),
1481            sigma_threshold: default_anomaly_sigma_threshold(),
1482            check_cost: true,
1483            check_tokens: true,
1484            check_velocity: true,
1485            check_latency: true,
1486        }
1487    }
1488}
1489
1490/// Configuration for real-time streaming security analysis.
1491///
1492/// When enabled, the proxy runs lightweight regex-based security pattern checks
1493/// incrementally during SSE streaming — every N tokens — producing interim
1494/// `SecurityFinding`s before the stream completes. This provides an early
1495/// warning layer; the full security analysis still runs after stream completion.
1496///
1497/// # Example (YAML)
1498///
1499/// ```yaml
1500/// streaming_analysis:
1501///   enabled: true
1502///   token_interval: 50
1503/// ```
1504#[derive(Debug, Clone, Serialize, Deserialize)]
1505pub struct StreamingAnalysisConfig {
1506    /// Enable incremental security analysis during SSE streaming.
1507    #[serde(default)]
1508    pub enabled: bool,
1509    /// Number of tokens between each incremental analysis check.
1510    #[serde(default = "default_streaming_token_interval")]
1511    pub token_interval: u32,
1512    /// Enable output-side analysis during SSE streaming (PII, secrets, toxicity on response content).
1513    #[serde(default)]
1514    pub output_enabled: bool,
1515    /// If a critical finding is detected mid-stream, inject a warning and stop.
1516    #[serde(default)]
1517    pub early_stop_on_critical: bool,
1518}
1519
1520fn default_streaming_token_interval() -> u32 {
1521    50
1522}
1523
1524impl Default for StreamingAnalysisConfig {
1525    fn default() -> Self {
1526        Self {
1527            enabled: false,
1528            token_interval: default_streaming_token_interval(),
1529            output_enabled: false,
1530            early_stop_on_critical: false,
1531        }
1532    }
1533}
1534
1535// ---------------------------------------------------------------------------
1536// Output safety configuration
1537// ---------------------------------------------------------------------------
1538
1539/// Output safety configuration for response content analysis.
1540///
1541/// When enabled, the proxy analyses LLM response content for toxicity,
1542/// PII leakage, secret exposure, and hallucination detection. This is a
1543/// post-processing step that runs after the upstream response is received.
1544///
1545/// # Example (YAML)
1546///
1547/// ```yaml
1548/// output_safety:
1549///   enabled: true
1550///   toxicity_enabled: true
1551///   toxicity_threshold: 0.7
1552///   block_on_critical: false
1553///   hallucination_enabled: false
1554///   hallucination_model: "vectara/hallucination_evaluation_model"
1555///   hallucination_threshold: 0.5
1556///   hallucination_min_response_length: 50
1557/// ```
1558#[derive(Debug, Clone, Serialize, Deserialize)]
1559pub struct OutputSafetyConfig {
1560    /// Enable output safety analysis on LLM responses.
1561    #[serde(default)]
1562    pub enabled: bool,
1563    /// Enable toxicity detection on response content.
1564    #[serde(default)]
1565    pub toxicity_enabled: bool,
1566    /// Confidence threshold for toxicity detection (0.0–1.0).
1567    #[serde(default = "default_toxicity_threshold")]
1568    pub toxicity_threshold: f32,
1569    /// Block (replace) the response if critical toxicity is detected.
1570    #[serde(default)]
1571    pub block_on_critical: bool,
1572    /// Enable hallucination detection on response content.
1573    ///
1574    /// When enabled, response sentences are scored against the user's prompt
1575    /// for factual consistency using a cross-encoder model.
1576    #[serde(default)]
1577    pub hallucination_enabled: bool,
1578    /// HuggingFace model ID for hallucination detection.
1579    #[serde(default = "default_hallucination_model")]
1580    pub hallucination_model: String,
1581    /// Threshold below which a sentence is considered potentially hallucinated
1582    /// (0.0–1.0). Sentences scoring below this are flagged.
1583    #[serde(default = "default_hallucination_threshold")]
1584    pub hallucination_threshold: f32,
1585    /// Minimum response length (in characters) to run hallucination detection.
1586    /// Responses shorter than this are skipped to save compute.
1587    #[serde(default = "default_hallucination_min_response_length")]
1588    pub hallucination_min_response_length: usize,
1589}
1590
1591fn default_toxicity_threshold() -> f32 {
1592    0.7
1593}
1594
1595fn default_hallucination_model() -> String {
1596    "vectara/hallucination_evaluation_model".to_string()
1597}
1598
1599fn default_hallucination_threshold() -> f32 {
1600    0.5
1601}
1602
1603fn default_hallucination_min_response_length() -> usize {
1604    50
1605}
1606
1607impl Default for OutputSafetyConfig {
1608    fn default() -> Self {
1609        Self {
1610            enabled: false,
1611            toxicity_enabled: false,
1612            toxicity_threshold: default_toxicity_threshold(),
1613            block_on_critical: false,
1614            hallucination_enabled: false,
1615            hallucination_model: default_hallucination_model(),
1616            hallucination_threshold: default_hallucination_threshold(),
1617            hallucination_min_response_length: default_hallucination_min_response_length(),
1618        }
1619    }
1620}
1621
1622// ---------------------------------------------------------------------------
1623// PII detection & redaction types
1624// ---------------------------------------------------------------------------
1625
1626/// Action to take when PII is detected in request or response content.
1627#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1628#[serde(rename_all = "snake_case")]
1629pub enum PiiAction {
1630    /// Generate security findings but do not modify text (default).
1631    #[default]
1632    AlertOnly,
1633    /// Generate security findings *and* replace PII with `[PII:TYPE]` tags.
1634    AlertAndRedact,
1635    /// Replace PII silently without generating security findings.
1636    RedactSilent,
1637}
1638
1639impl std::fmt::Display for PiiAction {
1640    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1641        match self {
1642            Self::AlertOnly => write!(f, "alert_only"),
1643            Self::AlertAndRedact => write!(f, "alert_and_redact"),
1644            Self::RedactSilent => write!(f, "redact_silent"),
1645        }
1646    }
1647}
1648
1649/// PII detection and redaction configuration.
1650///
1651/// Controls how the security analyzer handles detected PII patterns.
1652///
1653/// # Example (YAML)
1654///
1655/// ```yaml
1656/// pii:
1657///   action: "alert_only"   # "alert_only" | "alert_and_redact" | "redact_silent"
1658/// ```
1659#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1660pub struct PiiConfig {
1661    /// Action to take when PII is detected.
1662    #[serde(default)]
1663    pub action: PiiAction,
1664}
1665
1666/// Graceful shutdown configuration.
1667///
1668/// Controls how the proxy handles SIGTERM/SIGINT signals during Kubernetes
1669/// pod termination. The proxy drains in-flight connections, waits for
1670/// background tasks (trace capture, security analysis) to complete, and
1671/// then exits cleanly.
1672///
1673/// # Example (YAML)
1674///
1675/// ```yaml
1676/// shutdown:
1677///   timeout_seconds: 30
1678/// ```
1679#[derive(Debug, Clone, Serialize, Deserialize)]
1680pub struct ShutdownConfig {
1681    /// Maximum seconds to wait for in-flight tasks to complete after a
1682    /// shutdown signal is received. After this timeout the process exits
1683    /// regardless of pending work.
1684    #[serde(default = "default_shutdown_timeout_seconds")]
1685    pub timeout_seconds: u64,
1686}
1687
1688fn default_shutdown_timeout_seconds() -> u64 {
1689    30
1690}
1691
1692impl Default for ShutdownConfig {
1693    fn default() -> Self {
1694        Self {
1695            timeout_seconds: default_shutdown_timeout_seconds(),
1696        }
1697    }
1698}
1699
1700/// Security analysis configuration for ML-based prompt injection detection.
1701///
1702/// Controls whether ML-based detection is enabled alongside regex-based analysis,
1703/// which HuggingFace model to use, the confidence threshold, and the local
1704/// model cache directory.
1705///
1706/// # Memory requirements
1707///
1708/// Each DeBERTa model requires ~400-600 MB of RAM. When `ml_enabled`,
1709/// `injecguard_enabled`, and `piguard_enabled` are all `true`, expect
1710/// ~1.2-1.8 GB total for three model instances. Plan host memory accordingly.
1711///
1712/// # Example (YAML)
1713///
1714/// ```yaml
1715/// security_analysis:
1716///   ml_enabled: true
1717///   ml_model: "protectai/deberta-v3-base-prompt-injection-v2"
1718///   ml_threshold: 0.8
1719///   ml_cache_dir: "~/.cache/llmtrace/models"
1720///   ml_preload: true
1721///   ml_download_timeout_seconds: 300
1722///   ner_enabled: true
1723///   ner_model: "dslim/bert-base-NER"
1724///   jailbreak_enabled: true
1725///   jailbreak_threshold: 0.7
1726/// ```
1727#[derive(Debug, Clone, Serialize, Deserialize)]
1728pub struct SecurityAnalysisConfig {
1729    /// Enable ML-based security analysis (requires `ml` feature in `llmtrace-security`).
1730    /// Enabled by default -- the Ensemble analyzer (regex + ML fusion) is the recommended path.
1731    #[serde(default = "default_ml_enabled")]
1732    pub ml_enabled: bool,
1733    /// HuggingFace model ID for ML-based prompt injection detection.
1734    #[serde(default = "default_ml_model")]
1735    pub ml_model: String,
1736    /// Confidence threshold for ML detection (0.0–1.0).
1737    #[serde(default = "default_ml_threshold")]
1738    pub ml_threshold: f64,
1739    /// Local cache directory for downloaded ML models.
1740    #[serde(default = "default_ml_cache_dir")]
1741    pub ml_cache_dir: String,
1742    /// Pre-load ML models at proxy startup rather than on first request.
1743    #[serde(default = "default_ml_preload")]
1744    pub ml_preload: bool,
1745    /// Timeout in seconds for downloading ML models at startup.
1746    #[serde(default = "default_ml_download_timeout_seconds")]
1747    pub ml_download_timeout_seconds: u64,
1748    /// Enable ML-based NER for PII detection (person names, orgs, locations).
1749    #[serde(default)]
1750    pub ner_enabled: bool,
1751    /// HuggingFace model ID for NER-based PII detection.
1752    #[serde(default = "default_ner_model")]
1753    pub ner_model: String,
1754    /// Enable feature-level fusion classifier (ADR-013).
1755    ///
1756    /// When `true`, the ensemble concatenates DeBERTa embeddings with heuristic
1757    /// feature vectors and feeds them through a learned fusion classifier instead
1758    /// of combining scores after independent classification.
1759    #[serde(default)]
1760    pub fusion_enabled: bool,
1761    /// Optional file path for trained fusion classifier weights.
1762    ///
1763    /// When `None`, the fusion classifier is initialised with random weights
1764    /// (suitable for architecture validation; not for production inference).
1765    #[serde(default)]
1766    pub fusion_model_path: Option<String>,
1767    /// Enable dedicated jailbreak detection (runs alongside prompt injection).
1768    ///
1769    /// When `true` (the default when security analysis is enabled), a separate
1770    /// jailbreak detector with heuristic patterns and encoding evasion checks
1771    /// is run on every request.
1772    #[serde(default = "default_jailbreak_enabled")]
1773    pub jailbreak_enabled: bool,
1774    /// Confidence threshold for jailbreak detection (0.0–1.0).
1775    #[serde(default = "default_jailbreak_threshold")]
1776    pub jailbreak_threshold: f32,
1777    /// Enable InjecGuard as a third injection detector in the Ensemble.
1778    ///
1779    /// When `true`, the ensemble uses majority voting (regex + ML + InjecGuard)
1780    /// to suppress false positives from individual detectors.
1781    /// Adds ~400-600 MB memory for the InjecGuard DeBERTa-v3 model.
1782    #[serde(default)]
1783    pub injecguard_enabled: bool,
1784    /// HuggingFace model ID for the InjecGuard model.
1785    #[serde(default = "default_injecguard_model")]
1786    pub injecguard_model: String,
1787    /// Confidence threshold for InjecGuard detection (0.0-1.0).
1788    #[serde(default = "default_injecguard_threshold")]
1789    pub injecguard_threshold: f64,
1790    /// Enable PIGuard as an additional injection detector in the Ensemble.
1791    ///
1792    /// PIGuard uses DeBERTa + MOF (Mitigating Over-defense for Free) training
1793    /// to reduce trigger-word false positives. Adds ~400-600 MB memory.
1794    #[serde(default)]
1795    pub piguard_enabled: bool,
1796    /// HuggingFace model ID for the PIGuard model.
1797    #[serde(default = "default_piguard_model")]
1798    pub piguard_model: String,
1799    /// Confidence threshold for PIGuard detection (0.0-1.0).
1800    #[serde(default = "default_piguard_threshold")]
1801    pub piguard_threshold: f64,
1802    /// Operating point for ensemble thresholds.
1803    #[serde(default)]
1804    pub operating_point: OperatingPoint,
1805    /// Enable over-defence suppression to reduce false positives on benign content.
1806    #[serde(default)]
1807    pub over_defence: bool,
1808}
1809
1810fn default_ml_enabled() -> bool {
1811    true
1812}
1813
1814fn default_ml_model() -> String {
1815    "protectai/deberta-v3-base-prompt-injection-v2".to_string()
1816}
1817
1818fn default_ml_threshold() -> f64 {
1819    0.8
1820}
1821
1822fn default_ml_cache_dir() -> String {
1823    "~/.cache/llmtrace/models".to_string()
1824}
1825
1826fn default_ml_preload() -> bool {
1827    true
1828}
1829
1830fn default_ml_download_timeout_seconds() -> u64 {
1831    300
1832}
1833
1834fn default_ner_model() -> String {
1835    "dslim/bert-base-NER".to_string()
1836}
1837
1838fn default_jailbreak_enabled() -> bool {
1839    true
1840}
1841
1842fn default_jailbreak_threshold() -> f32 {
1843    0.7
1844}
1845
1846fn default_injecguard_model() -> String {
1847    "leolee99/InjecGuard".to_string()
1848}
1849
1850fn default_injecguard_threshold() -> f64 {
1851    0.85
1852}
1853
1854fn default_piguard_model() -> String {
1855    "leolee99/PIGuard".to_string()
1856}
1857
1858fn default_piguard_threshold() -> f64 {
1859    0.85
1860}
1861
1862impl Default for SecurityAnalysisConfig {
1863    fn default() -> Self {
1864        Self {
1865            ml_enabled: default_ml_enabled(),
1866            ml_model: default_ml_model(),
1867            ml_threshold: default_ml_threshold(),
1868            ml_cache_dir: default_ml_cache_dir(),
1869            ml_preload: default_ml_preload(),
1870            ml_download_timeout_seconds: default_ml_download_timeout_seconds(),
1871            ner_enabled: false,
1872            ner_model: default_ner_model(),
1873            fusion_enabled: false,
1874            fusion_model_path: None,
1875            jailbreak_enabled: default_jailbreak_enabled(),
1876            jailbreak_threshold: default_jailbreak_threshold(),
1877            injecguard_enabled: false,
1878            injecguard_model: default_injecguard_model(),
1879            injecguard_threshold: default_injecguard_threshold(),
1880            piguard_enabled: false,
1881            piguard_model: default_piguard_model(),
1882            piguard_threshold: default_piguard_threshold(),
1883            operating_point: OperatingPoint::default(),
1884            over_defence: false,
1885        }
1886    }
1887}
1888
1889// ---------------------------------------------------------------------------
1890// Enforcement configuration
1891// ---------------------------------------------------------------------------
1892
1893/// Enforcement mode for the proxy.
1894#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1895#[serde(rename_all = "snake_case")]
1896pub enum EnforcementMode {
1897    /// Log findings only (current behavior). Default for backward compat.
1898    #[default]
1899    Log,
1900    /// Block requests that trigger enforcement. Return 403.
1901    Block,
1902    /// Forward to upstream but attach finding metadata as response headers.
1903    Flag,
1904}
1905
1906/// Analysis depth for enforcement.
1907#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1908#[serde(rename_all = "snake_case")]
1909pub enum AnalysisDepth {
1910    /// Regex-only analysis. Near-zero added latency.
1911    #[default]
1912    Fast,
1913    /// Full ensemble analysis (regex + ML). Adds ML inference latency.
1914    Full,
1915}
1916
1917/// Per-category enforcement override.
1918#[derive(Debug, Clone, Serialize, Deserialize)]
1919pub struct CategoryEnforcement {
1920    /// The finding type to match (e.g. "prompt_injection", "jailbreak").
1921    pub finding_type: String,
1922    /// Enforcement action for this category.
1923    pub action: EnforcementMode,
1924}
1925
1926fn default_enforcement_min_severity() -> SecuritySeverity {
1927    SecuritySeverity::High
1928}
1929
1930fn default_enforcement_min_confidence() -> f64 {
1931    0.8
1932}
1933
1934fn default_enforcement_timeout_ms() -> u64 {
1935    2000
1936}
1937
1938/// Security enforcement configuration.
1939///
1940/// Controls whether the proxy acts on security findings before forwarding
1941/// requests upstream. Default is `log` mode (current behavior unchanged).
1942#[derive(Debug, Clone, Serialize, Deserialize)]
1943pub struct EnforcementConfig {
1944    /// Default enforcement mode.
1945    #[serde(default)]
1946    pub mode: EnforcementMode,
1947    /// Analysis depth: "fast" (regex only) or "full" (regex + ML ensemble).
1948    #[serde(default)]
1949    pub analysis_depth: AnalysisDepth,
1950    /// Minimum severity level to enforce on.
1951    #[serde(default = "default_enforcement_min_severity")]
1952    pub min_severity: SecuritySeverity,
1953    /// Minimum confidence score to enforce on (0.0-1.0).
1954    #[serde(default = "default_enforcement_min_confidence")]
1955    pub min_confidence: f64,
1956    /// Timeout for enforcement analysis in milliseconds. Fail-open on timeout.
1957    #[serde(default = "default_enforcement_timeout_ms")]
1958    pub timeout_ms: u64,
1959    /// Per-category enforcement overrides.
1960    #[serde(default)]
1961    pub categories: Vec<CategoryEnforcement>,
1962}
1963
1964impl Default for EnforcementConfig {
1965    fn default() -> Self {
1966        Self {
1967            mode: EnforcementMode::Log,
1968            analysis_depth: AnalysisDepth::Fast,
1969            min_severity: default_enforcement_min_severity(),
1970            min_confidence: default_enforcement_min_confidence(),
1971            timeout_ms: default_enforcement_timeout_ms(),
1972            categories: Vec::new(),
1973        }
1974    }
1975}
1976
1977/// OpenTelemetry OTLP ingestion configuration.
1978///
1979/// When enabled, the proxy exposes `POST /v1/traces` to accept traces in
1980/// the standard OTLP/HTTP format (JSON and protobuf).
1981///
1982/// # Example (YAML)
1983///
1984/// ```yaml
1985/// otel_ingest:
1986///   enabled: true
1987/// ```
1988#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1989pub struct OtelIngestConfig {
1990    /// Enable the OTLP/HTTP ingestion endpoint.
1991    #[serde(default)]
1992    pub enabled: bool,
1993}
1994
1995/// Authentication and RBAC configuration.
1996///
1997/// When `enabled` is `true`, every request (except `/health`) must carry
1998/// a valid API key in the `Authorization: Bearer <key>` header. The
1999/// `admin_key` serves as a bootstrap key for initial setup.
2000///
2001/// # Example (YAML)
2002///
2003/// ```yaml
2004/// auth:
2005///   enabled: true
2006///   admin_key: "llmt_bootstrap_secret"
2007/// ```
2008#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2009pub struct AuthConfig {
2010    /// Enable API-key authentication and RBAC enforcement.
2011    #[serde(default)]
2012    pub enabled: bool,
2013    /// Bootstrap admin key — grants full admin access when auth is enabled.
2014    /// This key is not stored hashed; it's compared directly from config.
2015    #[serde(default)]
2016    pub admin_key: Option<String>,
2017}
2018
2019/// gRPC ingestion gateway configuration.
2020///
2021/// When enabled, the proxy starts a `tonic` gRPC server on a separate
2022/// listen address that accepts traces in the LLMTrace-native protobuf
2023/// format. Supports both unary and client-streaming ingestion RPCs.
2024///
2025/// # Example (YAML)
2026///
2027/// ```yaml
2028/// grpc:
2029///   enabled: true
2030///   listen_addr: "0.0.0.0:50051"
2031/// ```
2032#[derive(Debug, Clone, Serialize, Deserialize)]
2033pub struct GrpcConfig {
2034    /// Enable the gRPC ingestion endpoint.
2035    #[serde(default)]
2036    pub enabled: bool,
2037    /// Address and port to bind the gRPC server to.
2038    #[serde(default = "default_grpc_listen_addr")]
2039    pub listen_addr: String,
2040}
2041
2042fn default_grpc_listen_addr() -> String {
2043    "0.0.0.0:50051".to_string()
2044}
2045
2046impl Default for GrpcConfig {
2047    fn default() -> Self {
2048        Self {
2049            enabled: false,
2050            listen_addr: default_grpc_listen_addr(),
2051        }
2052    }
2053}
2054
2055/// Alert engine configuration for webhook notifications.
2056///
2057/// Supports both a legacy single-webhook mode (via `webhook_url`) and a
2058/// multi-channel mode (via `channels`). When `channels` is non-empty it
2059/// takes precedence; otherwise the legacy `webhook_url` is wrapped in a
2060/// single `WebhookChannelConfig` for backward compatibility.
2061#[derive(Debug, Clone, Serialize, Deserialize)]
2062pub struct AlertConfig {
2063    /// Enable the alert engine.
2064    #[serde(default)]
2065    pub enabled: bool,
2066    /// **Legacy** — Webhook URL to POST alert payloads to.
2067    /// Ignored when `channels` is non-empty.
2068    #[serde(default)]
2069    pub webhook_url: String,
2070    /// **Legacy** — Minimum severity level (e.g. `"High"`).
2071    /// Used as the global default when `channels` is empty.
2072    #[serde(default = "default_alert_min_severity")]
2073    pub min_severity: String,
2074    /// **Legacy** — Minimum confidence-based score (0–100).
2075    #[serde(default = "default_alert_min_security_score")]
2076    pub min_security_score: u8,
2077    /// Cooldown in seconds between repeated alerts for the same finding type.
2078    #[serde(default = "default_alert_cooldown_seconds")]
2079    pub cooldown_seconds: u64,
2080    /// Multi-channel alert destinations.
2081    /// When non-empty, each channel has its own type, URL, and min_severity.
2082    #[serde(default)]
2083    pub channels: Vec<AlertChannelConfig>,
2084    /// Optional escalation configuration.
2085    #[serde(default)]
2086    pub escalation: Option<AlertEscalationConfig>,
2087}
2088
2089/// Configuration for a single alert channel.
2090#[derive(Debug, Clone, Serialize, Deserialize)]
2091pub struct AlertChannelConfig {
2092    /// Channel type: `"webhook"`, `"slack"`, `"pagerduty"`, or `"email"`.
2093    #[serde(rename = "type")]
2094    pub channel_type: String,
2095    /// Webhook / Slack incoming-webhook URL.
2096    #[serde(default)]
2097    pub url: Option<String>,
2098    /// Alias accepted for `url` (convenience for webhook channels).
2099    #[serde(default)]
2100    pub webhook_url: Option<String>,
2101    /// PagerDuty Events API v2 routing key.
2102    #[serde(default)]
2103    pub routing_key: Option<String>,
2104    /// Minimum severity to send to this channel (default: `"High"`).
2105    #[serde(default = "default_alert_min_severity")]
2106    pub min_severity: String,
2107    /// Minimum confidence-based score (0–100) to send to this channel.
2108    #[serde(default = "default_alert_min_security_score")]
2109    pub min_security_score: u8,
2110}
2111
2112impl AlertChannelConfig {
2113    /// Resolve the effective URL (prefers `url`, falls back to `webhook_url`).
2114    pub fn effective_url(&self) -> Option<&str> {
2115        self.url
2116            .as_deref()
2117            .or(self.webhook_url.as_deref())
2118            .filter(|s| !s.is_empty())
2119    }
2120}
2121
2122/// Optional alert escalation configuration.
2123///
2124/// If no acknowledgement is received within `escalate_after_seconds`, the
2125/// alert is re-sent at the next higher severity channel.
2126#[derive(Debug, Clone, Serialize, Deserialize)]
2127pub struct AlertEscalationConfig {
2128    /// Enable escalation.
2129    #[serde(default)]
2130    pub enabled: bool,
2131    /// Seconds to wait before escalating an unacknowledged alert.
2132    #[serde(default = "default_escalation_seconds")]
2133    pub escalate_after_seconds: u64,
2134}
2135
2136fn default_escalation_seconds() -> u64 {
2137    600
2138}
2139
2140fn default_alert_min_severity() -> String {
2141    "High".to_string()
2142}
2143
2144fn default_alert_min_security_score() -> u8 {
2145    70
2146}
2147
2148fn default_alert_cooldown_seconds() -> u64 {
2149    300
2150}
2151
2152impl Default for AlertConfig {
2153    fn default() -> Self {
2154        Self {
2155            enabled: false,
2156            webhook_url: String::new(),
2157            min_severity: default_alert_min_severity(),
2158            min_security_score: default_alert_min_security_score(),
2159            cooldown_seconds: default_alert_cooldown_seconds(),
2160            channels: Vec::new(),
2161            escalation: None,
2162        }
2163    }
2164}
2165
2166/// Logging configuration.
2167#[derive(Debug, Clone, Serialize, Deserialize)]
2168pub struct LoggingConfig {
2169    /// Log level: `trace`, `debug`, `info`, `warn`, or `error`.
2170    #[serde(default = "default_log_level")]
2171    pub level: String,
2172    /// Output format: `text` (human-readable) or `json` (structured).
2173    #[serde(default = "default_log_format")]
2174    pub format: String,
2175}
2176
2177fn default_log_level() -> String {
2178    "info".to_string()
2179}
2180
2181fn default_log_format() -> String {
2182    "text".to_string()
2183}
2184
2185impl Default for LoggingConfig {
2186    fn default() -> Self {
2187        Self {
2188            level: default_log_level(),
2189            format: default_log_format(),
2190        }
2191    }
2192}
2193
2194// ---------------------------------------------------------------------------
2195// Error types
2196// ---------------------------------------------------------------------------
2197
2198/// Core error types.
2199#[derive(thiserror::Error, Debug)]
2200pub enum LLMTraceError {
2201    /// Storage layer error.
2202    #[error("Storage error: {0}")]
2203    Storage(String),
2204
2205    /// Security analysis error.
2206    #[error("Security analysis error: {0}")]
2207    Security(String),
2208
2209    /// Serialization / deserialization error.
2210    #[error("Serialization error: {0}")]
2211    Serialization(#[from] serde_json::Error),
2212
2213    /// Invalid tenant identifier.
2214    #[error("Invalid tenant: {tenant_id}")]
2215    InvalidTenant {
2216        /// The invalid tenant ID.
2217        tenant_id: TenantId,
2218    },
2219
2220    /// Configuration error.
2221    #[error("Configuration error: {0}")]
2222    Config(String),
2223}
2224
2225/// Convenience alias for `std::result::Result<T, LLMTraceError>`.
2226pub type Result<T> = std::result::Result<T, LLMTraceError>;
2227
2228// ---------------------------------------------------------------------------
2229// Query types
2230// ---------------------------------------------------------------------------
2231
2232/// Query parameters for filtering traces.
2233#[derive(Debug, Clone, Serialize, Deserialize)]
2234pub struct TraceQuery {
2235    /// Tenant to query traces for.
2236    pub tenant_id: TenantId,
2237    /// Start time for the query range.
2238    pub start_time: Option<DateTime<Utc>>,
2239    /// End time for the query range.
2240    pub end_time: Option<DateTime<Utc>>,
2241    /// Filter by provider.
2242    pub provider: Option<LLMProvider>,
2243    /// Filter by model name.
2244    pub model_name: Option<String>,
2245    /// Filter by operation name.
2246    pub operation_name: Option<String>,
2247    /// Minimum security score to include.
2248    pub min_security_score: Option<u8>,
2249    /// Maximum security score to include.
2250    pub max_security_score: Option<u8>,
2251    /// Filter by trace ID.
2252    pub trace_id: Option<Uuid>,
2253    /// Filter by tags.
2254    pub tags: HashMap<String, String>,
2255    /// Maximum number of results to return.
2256    pub limit: Option<u32>,
2257    /// Number of results to skip (for pagination).
2258    pub offset: Option<u32>,
2259}
2260
2261impl TraceQuery {
2262    /// Create a new trace query for a tenant.
2263    pub fn new(tenant_id: TenantId) -> Self {
2264        Self {
2265            tenant_id,
2266            start_time: None,
2267            end_time: None,
2268            provider: None,
2269            model_name: None,
2270            operation_name: None,
2271            min_security_score: None,
2272            max_security_score: None,
2273            trace_id: None,
2274            tags: HashMap::new(),
2275            limit: None,
2276            offset: None,
2277        }
2278    }
2279
2280    /// Add a time range filter.
2281    pub fn with_time_range(mut self, start: DateTime<Utc>, end: DateTime<Utc>) -> Self {
2282        self.start_time = Some(start);
2283        self.end_time = Some(end);
2284        self
2285    }
2286
2287    /// Add a provider filter.
2288    pub fn with_provider(mut self, provider: LLMProvider) -> Self {
2289        self.provider = Some(provider);
2290        self
2291    }
2292
2293    /// Add a security score range filter.
2294    pub fn with_security_score_range(mut self, min: u8, max: u8) -> Self {
2295        self.min_security_score = Some(min);
2296        self.max_security_score = Some(max);
2297        self
2298    }
2299
2300    /// Set the limit.
2301    pub fn with_limit(mut self, limit: u32) -> Self {
2302        self.limit = Some(limit);
2303        self
2304    }
2305}
2306
2307// ---------------------------------------------------------------------------
2308// Storage statistics
2309// ---------------------------------------------------------------------------
2310
2311/// Storage statistics for a tenant.
2312#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
2313pub struct StorageStats {
2314    /// Total number of traces stored.
2315    pub total_traces: u64,
2316    /// Total number of spans stored.
2317    pub total_spans: u64,
2318    /// Storage size in bytes.
2319    pub storage_size_bytes: u64,
2320    /// Oldest trace timestamp.
2321    #[schema(value_type = String, format = "date-time")]
2322    pub oldest_trace: Option<DateTime<Utc>>,
2323    /// Newest trace timestamp.
2324    #[schema(value_type = String, format = "date-time")]
2325    pub newest_trace: Option<DateTime<Utc>>,
2326}
2327
2328// ---------------------------------------------------------------------------
2329// Repository traits (storage layer abstraction)
2330// ---------------------------------------------------------------------------
2331
2332/// Repository for trace and span storage (analytical, high-volume data).
2333///
2334/// Dev/Lite: SQLite. Production: ClickHouse.
2335#[async_trait::async_trait]
2336pub trait TraceRepository: Send + Sync {
2337    /// Store a trace event with all its spans.
2338    async fn store_trace(&self, trace: &TraceEvent) -> Result<()>;
2339
2340    /// Store a single trace span.
2341    async fn store_span(&self, span: &TraceSpan) -> Result<()>;
2342
2343    /// Query traces with filters.
2344    async fn query_traces(&self, query: &TraceQuery) -> Result<Vec<TraceEvent>>;
2345
2346    /// Query spans with filters.
2347    async fn query_spans(&self, query: &TraceQuery) -> Result<Vec<TraceSpan>>;
2348
2349    /// Get a specific trace by ID.
2350    async fn get_trace(&self, tenant_id: TenantId, trace_id: Uuid) -> Result<Option<TraceEvent>>;
2351
2352    /// Get a specific span by ID.
2353    async fn get_span(&self, tenant_id: TenantId, span_id: Uuid) -> Result<Option<TraceSpan>>;
2354
2355    /// Delete traces older than the specified time.
2356    async fn delete_traces_before(&self, tenant_id: TenantId, before: DateTime<Utc>)
2357        -> Result<u64>;
2358
2359    /// Get storage statistics for a tenant.
2360    async fn get_stats(&self, tenant_id: TenantId) -> Result<StorageStats>;
2361
2362    /// Health check for the trace repository.
2363    async fn health_check(&self) -> Result<()>;
2364}
2365
2366/// Repository for metadata (tenants, configs, audit events).
2367///
2368/// Dev/Lite: SQLite. Production: PostgreSQL.
2369#[async_trait::async_trait]
2370pub trait MetadataRepository: Send + Sync {
2371    /// Create a new tenant.
2372    async fn create_tenant(&self, tenant: &Tenant) -> Result<()>;
2373
2374    /// Get a tenant by ID.
2375    async fn get_tenant(&self, id: TenantId) -> Result<Option<Tenant>>;
2376
2377    /// Get a tenant by its API token.
2378    async fn get_tenant_by_token(&self, token: &str) -> Result<Option<Tenant>>;
2379
2380    /// Update an existing tenant.
2381    async fn update_tenant(&self, tenant: &Tenant) -> Result<()>;
2382
2383    /// List all tenants.
2384    async fn list_tenants(&self) -> Result<Vec<Tenant>>;
2385
2386    /// Delete a tenant by ID.
2387    async fn delete_tenant(&self, id: TenantId) -> Result<()>;
2388
2389    /// Get the configuration for a tenant.
2390    async fn get_tenant_config(&self, tenant_id: TenantId) -> Result<Option<TenantConfig>>;
2391
2392    /// Create or update tenant configuration.
2393    async fn upsert_tenant_config(&self, config: &TenantConfig) -> Result<()>;
2394
2395    /// Record an audit event.
2396    async fn record_audit_event(&self, event: &AuditEvent) -> Result<()>;
2397
2398    /// Query audit events.
2399    async fn query_audit_events(&self, query: &AuditQuery) -> Result<Vec<AuditEvent>>;
2400
2401    // -- API key management --------------------------------------------------
2402
2403    /// Store a new API key record (the plaintext key is NOT stored — only its hash).
2404    async fn create_api_key(&self, key: &ApiKeyRecord) -> Result<()>;
2405
2406    /// Look up an active (non-revoked) API key by its SHA-256 hash.
2407    async fn get_api_key_by_hash(&self, key_hash: &str) -> Result<Option<ApiKeyRecord>>;
2408
2409    /// List all API keys for a tenant (includes revoked keys).
2410    async fn list_api_keys(&self, tenant_id: TenantId) -> Result<Vec<ApiKeyRecord>>;
2411
2412    /// Revoke an API key by setting its `revoked_at` timestamp.
2413    async fn revoke_api_key(&self, key_id: Uuid) -> Result<bool>;
2414
2415    // -- Compliance report persistence ---------------------------------------
2416
2417    /// Store a compliance report (insert or update).
2418    async fn store_report(&self, report: &ComplianceReportRecord) -> Result<()>;
2419
2420    /// Get a compliance report by ID.
2421    async fn get_report(&self, report_id: Uuid) -> Result<Option<ComplianceReportRecord>>;
2422
2423    /// List compliance reports for a tenant with pagination.
2424    async fn list_reports(&self, query: &ReportQuery) -> Result<Vec<ComplianceReportRecord>>;
2425
2426    /// Health check for the metadata repository.
2427    async fn health_check(&self) -> Result<()>;
2428}
2429
2430/// Cache layer for hot queries and sessions.
2431///
2432/// Dev/Lite: In-memory `DashMap`. Production: Redis.
2433#[async_trait::async_trait]
2434pub trait CacheLayer: Send + Sync {
2435    /// Get a cached value by key.
2436    async fn get(&self, key: &str) -> Result<Option<Vec<u8>>>;
2437
2438    /// Set a cached value with a TTL.
2439    async fn set(&self, key: &str, value: &[u8], ttl: Duration) -> Result<()>;
2440
2441    /// Invalidate (remove) a cached entry.
2442    async fn invalidate(&self, key: &str) -> Result<()>;
2443
2444    /// Health check for the cache layer.
2445    async fn health_check(&self) -> Result<()>;
2446}
2447
2448// ---------------------------------------------------------------------------
2449// Composite Storage struct
2450// ---------------------------------------------------------------------------
2451
2452/// Composite storage that wires together the three repository concerns.
2453///
2454/// Consumers receive a single `Storage` value instead of managing three
2455/// separate `Arc<dyn …>` handles.
2456pub struct Storage {
2457    /// Trace & span repository (analytical, high-volume).
2458    pub traces: Arc<dyn TraceRepository>,
2459    /// Metadata repository (tenants, configs, audit).
2460    pub metadata: Arc<dyn MetadataRepository>,
2461    /// Cache layer (hot queries, sessions).
2462    pub cache: Arc<dyn CacheLayer>,
2463}
2464
2465// ---------------------------------------------------------------------------
2466// Analysis context & SecurityAnalyzer trait
2467// ---------------------------------------------------------------------------
2468
2469/// Analysis context for security analyzers.
2470#[derive(Debug, Clone)]
2471pub struct AnalysisContext {
2472    /// Tenant ID for context.
2473    pub tenant_id: TenantId,
2474    /// Trace ID for context.
2475    pub trace_id: Uuid,
2476    /// Span ID for context.
2477    pub span_id: Uuid,
2478    /// LLM provider being used.
2479    pub provider: LLMProvider,
2480    /// Model name.
2481    pub model_name: String,
2482    /// Custom analysis parameters.
2483    pub parameters: HashMap<String, String>,
2484}
2485
2486/// Trait for security analyzers.
2487#[async_trait::async_trait]
2488pub trait SecurityAnalyzer: Send + Sync {
2489    /// Analyze a request for security issues.
2490    async fn analyze_request(
2491        &self,
2492        prompt: &str,
2493        context: &AnalysisContext,
2494    ) -> Result<Vec<SecurityFinding>>;
2495
2496    /// Analyze a response for security issues.
2497    async fn analyze_response(
2498        &self,
2499        response: &str,
2500        context: &AnalysisContext,
2501    ) -> Result<Vec<SecurityFinding>>;
2502
2503    /// Analyze a complete request/response pair.
2504    async fn analyze_interaction(
2505        &self,
2506        prompt: &str,
2507        response: &str,
2508        context: &AnalysisContext,
2509    ) -> Result<Vec<SecurityFinding>> {
2510        let mut findings = Vec::new();
2511        findings.extend(self.analyze_request(prompt, context).await?);
2512        findings.extend(self.analyze_response(response, context).await?);
2513        Ok(findings)
2514    }
2515
2516    /// Get the analyzer name.
2517    fn name(&self) -> &'static str;
2518
2519    /// Get the analyzer version.
2520    fn version(&self) -> &'static str;
2521
2522    /// Get supported security finding types.
2523    fn supported_finding_types(&self) -> Vec<String>;
2524
2525    /// Check if the analyzer is healthy.
2526    async fn health_check(&self) -> Result<()>;
2527}
2528
2529// ---------------------------------------------------------------------------
2530// Tests
2531// ---------------------------------------------------------------------------
2532
2533#[cfg(test)]
2534mod tests {
2535    use super::*;
2536
2537    #[test]
2538    fn test_tenant_id_creation() {
2539        let tenant1 = TenantId::new();
2540        let tenant2 = TenantId::new();
2541        assert_ne!(tenant1, tenant2);
2542    }
2543
2544    #[test]
2545    fn test_tenant_id_display() {
2546        let tenant_id = TenantId::new();
2547        let display_str = format!("{}", tenant_id);
2548        let uuid_str = format!("{}", tenant_id.0);
2549        assert_eq!(display_str, uuid_str);
2550    }
2551
2552    #[test]
2553    fn test_security_finding_creation() {
2554        let finding = SecurityFinding::new(
2555            SecuritySeverity::High,
2556            "prompt_injection".to_string(),
2557            "Detected system prompt override attempt".to_string(),
2558            0.95,
2559        );
2560
2561        assert_eq!(finding.severity, SecuritySeverity::High);
2562        assert_eq!(finding.finding_type, "prompt_injection");
2563        assert!(finding.requires_alert);
2564        assert_eq!(finding.confidence_score, 0.95);
2565        assert!(finding.metadata.is_empty());
2566    }
2567
2568    #[test]
2569    fn test_security_finding_with_metadata() {
2570        let finding = SecurityFinding::new(
2571            SecuritySeverity::Medium,
2572            "pii_detection".to_string(),
2573            "Found potential PII".to_string(),
2574            0.8,
2575        )
2576        .with_location("request.messages[0].content".to_string())
2577        .with_metadata("pii_type".to_string(), "email".to_string())
2578        .with_alert_required(true);
2579
2580        assert_eq!(
2581            finding.location,
2582            Some("request.messages[0].content".to_string())
2583        );
2584        assert_eq!(finding.metadata.get("pii_type"), Some(&"email".to_string()));
2585        assert!(finding.requires_alert);
2586    }
2587
2588    #[test]
2589    fn test_trace_span_creation() {
2590        let tenant_id = TenantId::new();
2591        let trace_id = Uuid::new_v4();
2592
2593        let span = TraceSpan::new(
2594            trace_id,
2595            tenant_id,
2596            "chat_completion".to_string(),
2597            LLMProvider::OpenAI,
2598            "gpt-4".to_string(),
2599            "Hello, how are you?".to_string(),
2600        );
2601
2602        assert_eq!(span.trace_id, trace_id);
2603        assert_eq!(span.tenant_id, tenant_id);
2604        assert_eq!(span.operation_name, "chat_completion");
2605        assert_eq!(span.provider, LLMProvider::OpenAI);
2606        assert!(span.response.is_none());
2607        assert!(!span.is_complete());
2608        assert!(!span.is_failed());
2609    }
2610
2611    #[test]
2612    fn test_trace_span_completion() {
2613        let span = TraceSpan::new(
2614            Uuid::new_v4(),
2615            TenantId::new(),
2616            "test_op".to_string(),
2617            LLMProvider::OpenAI,
2618            "gpt-3.5-turbo".to_string(),
2619            "test prompt".to_string(),
2620        );
2621
2622        let completed_span = span.finish_with_response("test response".to_string());
2623
2624        assert_eq!(completed_span.response, Some("test response".to_string()));
2625        assert!(completed_span.is_complete());
2626        assert!(!completed_span.is_failed());
2627        assert!(completed_span.end_time.is_some());
2628        assert!(completed_span.duration_ms.is_some());
2629    }
2630
2631    #[test]
2632    fn test_trace_span_error() {
2633        let span = TraceSpan::new(
2634            Uuid::new_v4(),
2635            TenantId::new(),
2636            "test_op".to_string(),
2637            LLMProvider::OpenAI,
2638            "gpt-4".to_string(),
2639            "test prompt".to_string(),
2640        );
2641
2642        let failed_span = span.finish_with_error("API timeout".to_string(), Some(504));
2643
2644        assert!(failed_span.is_failed());
2645        assert!(failed_span.is_complete());
2646        assert_eq!(failed_span.error_message, Some("API timeout".to_string()));
2647        assert_eq!(failed_span.status_code, Some(504));
2648    }
2649
2650    #[test]
2651    fn test_trace_span_security_findings() {
2652        let mut span = TraceSpan::new(
2653            Uuid::new_v4(),
2654            TenantId::new(),
2655            "test_op".to_string(),
2656            LLMProvider::OpenAI,
2657            "gpt-4".to_string(),
2658            "test prompt".to_string(),
2659        );
2660
2661        let finding = SecurityFinding::new(
2662            SecuritySeverity::Critical,
2663            "prompt_injection".to_string(),
2664            "Malicious prompt detected".to_string(),
2665            0.98,
2666        );
2667
2668        span.add_security_finding(finding);
2669
2670        assert_eq!(span.security_findings.len(), 1);
2671        assert_eq!(span.security_score, Some(95));
2672    }
2673
2674    #[test]
2675    fn test_span_event_creation() {
2676        let event = SpanEvent::new(
2677            "token_received".to_string(),
2678            "First token received from LLM".to_string(),
2679        )
2680        .with_data("token".to_string(), "Hello".to_string())
2681        .with_data("position".to_string(), "0".to_string());
2682
2683        assert_eq!(event.event_type, "token_received");
2684        assert_eq!(event.data.get("token"), Some(&"Hello".to_string()));
2685        assert_eq!(event.data.get("position"), Some(&"0".to_string()));
2686    }
2687
2688    #[test]
2689    fn test_trace_query_creation() {
2690        let tenant_id = TenantId::new();
2691        let provider = LLMProvider::Anthropic;
2692        let start_time = Utc::now() - chrono::Duration::hours(24);
2693        let end_time = Utc::now();
2694
2695        let query = TraceQuery::new(tenant_id)
2696            .with_time_range(start_time, end_time)
2697            .with_provider(provider.clone())
2698            .with_security_score_range(80, 100)
2699            .with_limit(50);
2700
2701        assert_eq!(query.tenant_id, tenant_id);
2702        assert_eq!(query.start_time, Some(start_time));
2703        assert_eq!(query.end_time, Some(end_time));
2704        assert_eq!(query.provider, Some(provider));
2705        assert_eq!(query.min_security_score, Some(80));
2706        assert_eq!(query.max_security_score, Some(100));
2707        assert_eq!(query.limit, Some(50));
2708    }
2709
2710    #[test]
2711    fn test_proxy_config_default() {
2712        let config = ProxyConfig::default();
2713
2714        assert_eq!(config.listen_addr, "0.0.0.0:8080");
2715        assert!(config.enable_security_analysis);
2716        assert!(config.enable_trace_storage);
2717        assert!(config.enable_streaming);
2718        assert_eq!(config.timeout_ms, 30000);
2719        assert_eq!(config.max_connections, 1000);
2720        assert!(!config.enable_tls);
2721        assert_eq!(config.logging.level, "info");
2722        assert_eq!(config.logging.format, "text");
2723        assert!(config.cost_estimation.enabled);
2724        assert!(config.cost_estimation.custom_models.is_empty());
2725    }
2726
2727    #[test]
2728    fn test_storage_config_default() {
2729        let config = StorageConfig::default();
2730        assert_eq!(config.profile, "lite");
2731        assert_eq!(config.database_path, "llmtrace.db");
2732        assert!(config.clickhouse_url.is_none());
2733        assert!(config.clickhouse_database.is_none());
2734        assert!(config.postgres_url.is_none());
2735        assert!(config.redis_url.is_none());
2736    }
2737
2738    #[test]
2739    fn test_logging_config_default() {
2740        let config = LoggingConfig::default();
2741        assert_eq!(config.level, "info");
2742        assert_eq!(config.format, "text");
2743    }
2744
2745    #[test]
2746    fn test_logging_config_serialization() {
2747        let config = LoggingConfig {
2748            level: "debug".to_string(),
2749            format: "json".to_string(),
2750        };
2751        let serialized = serde_json::to_string(&config).unwrap();
2752        let deserialized: LoggingConfig = serde_json::from_str(&serialized).unwrap();
2753        assert_eq!(config.level, deserialized.level);
2754        assert_eq!(config.format, deserialized.format);
2755    }
2756
2757    #[test]
2758    fn test_rate_limit_config_default() {
2759        let config = RateLimitConfig::default();
2760
2761        assert!(config.enabled);
2762        assert_eq!(config.requests_per_second, 100);
2763        assert_eq!(config.burst_size, 200);
2764        assert_eq!(config.window_seconds, 60);
2765    }
2766
2767    #[test]
2768    fn test_circuit_breaker_config_default() {
2769        let config = CircuitBreakerConfig::default();
2770
2771        assert!(config.enabled);
2772        assert_eq!(config.failure_threshold, 10);
2773        assert_eq!(config.recovery_timeout_ms, 30000);
2774        assert_eq!(config.half_open_max_calls, 3);
2775    }
2776
2777    #[test]
2778    fn test_health_check_config_default() {
2779        let config = HealthCheckConfig::default();
2780
2781        assert!(config.enabled);
2782        assert_eq!(config.path, "/health");
2783        assert_eq!(config.interval_seconds, 10);
2784        assert_eq!(config.timeout_ms, 5000);
2785        assert_eq!(config.retries, 3);
2786    }
2787
2788    #[test]
2789    fn test_llm_provider_serialization() {
2790        let providers = vec![
2791            LLMProvider::OpenAI,
2792            LLMProvider::Anthropic,
2793            LLMProvider::VLLm,
2794            LLMProvider::SGLang,
2795            LLMProvider::TGI,
2796            LLMProvider::Ollama,
2797            LLMProvider::AzureOpenAI,
2798            LLMProvider::Bedrock,
2799            LLMProvider::Custom("CustomProvider".to_string()),
2800        ];
2801
2802        for provider in providers {
2803            let serialized = serde_json::to_string(&provider).unwrap();
2804            let deserialized: LLMProvider = serde_json::from_str(&serialized).unwrap();
2805            assert_eq!(provider, deserialized);
2806        }
2807    }
2808
2809    #[test]
2810    fn test_security_severity_ordering() {
2811        assert!(SecuritySeverity::Critical > SecuritySeverity::High);
2812        assert!(SecuritySeverity::High > SecuritySeverity::Medium);
2813        assert!(SecuritySeverity::Medium > SecuritySeverity::Low);
2814        assert!(SecuritySeverity::Low > SecuritySeverity::Info);
2815    }
2816
2817    #[test]
2818    fn test_trace_serialization() {
2819        let tenant_id = TenantId::new();
2820        let trace_id = Uuid::new_v4();
2821
2822        let span = TraceSpan::new(
2823            trace_id,
2824            tenant_id,
2825            "chat_completion".to_string(),
2826            LLMProvider::OpenAI,
2827            "gpt-4".to_string(),
2828            "Hello, world!".to_string(),
2829        );
2830
2831        let trace = TraceEvent {
2832            trace_id,
2833            tenant_id,
2834            spans: vec![span],
2835            created_at: Utc::now(),
2836        };
2837
2838        let serialized = serde_json::to_string(&trace).unwrap();
2839        let deserialized: TraceEvent = serde_json::from_str(&serialized).unwrap();
2840
2841        assert_eq!(trace.trace_id, deserialized.trace_id);
2842        assert_eq!(trace.tenant_id, deserialized.tenant_id);
2843        assert_eq!(trace.spans.len(), deserialized.spans.len());
2844    }
2845
2846    #[test]
2847    fn test_security_finding_serialization() {
2848        let finding = SecurityFinding::new(
2849            SecuritySeverity::High,
2850            "prompt_injection".to_string(),
2851            "Detected system prompt override attempt".to_string(),
2852            0.95,
2853        )
2854        .with_location("request.messages[0]".to_string())
2855        .with_metadata("pattern".to_string(), "ignore_previous".to_string());
2856
2857        let serialized = serde_json::to_string(&finding).unwrap();
2858        let deserialized: SecurityFinding = serde_json::from_str(&serialized).unwrap();
2859
2860        assert_eq!(finding.severity, deserialized.severity);
2861        assert_eq!(finding.finding_type, deserialized.finding_type);
2862        assert_eq!(finding.confidence_score, deserialized.confidence_score);
2863        assert_eq!(finding.location, deserialized.location);
2864        assert_eq!(finding.metadata, deserialized.metadata);
2865    }
2866
2867    #[test]
2868    fn test_trace_span_serialization() {
2869        let mut span = TraceSpan::new(
2870            Uuid::new_v4(),
2871            TenantId::new(),
2872            "embedding".to_string(),
2873            LLMProvider::OpenAI,
2874            "text-embedding-ada-002".to_string(),
2875            "Test embedding text".to_string(),
2876        );
2877
2878        let event = SpanEvent::new(
2879            "embedding_complete".to_string(),
2880            "Embedding calculation completed".to_string(),
2881        );
2882        span.add_event(event);
2883
2884        let finding = SecurityFinding::new(
2885            SecuritySeverity::Low,
2886            "pii_detection".to_string(),
2887            "Potential email address detected".to_string(),
2888            0.7,
2889        );
2890        span.add_security_finding(finding);
2891
2892        let serialized = serde_json::to_string(&span).unwrap();
2893        let deserialized: TraceSpan = serde_json::from_str(&serialized).unwrap();
2894
2895        assert_eq!(span.trace_id, deserialized.trace_id);
2896        assert_eq!(span.operation_name, deserialized.operation_name);
2897        assert_eq!(span.provider, deserialized.provider);
2898        assert_eq!(span.events.len(), deserialized.events.len());
2899        assert_eq!(
2900            span.security_findings.len(),
2901            deserialized.security_findings.len()
2902        );
2903    }
2904
2905    #[test]
2906    fn test_proxy_config_serialization() {
2907        let mut custom_models = HashMap::new();
2908        custom_models.insert(
2909            "my-custom-model".to_string(),
2910            ModelPricingConfig {
2911                input_per_million: 1.0,
2912                output_per_million: 2.0,
2913            },
2914        );
2915
2916        let config = ProxyConfig {
2917            listen_addr: "127.0.0.1:9090".to_string(),
2918            upstream_url: "https://api.anthropic.com".to_string(),
2919            storage: StorageConfig {
2920                profile: "lite".to_string(),
2921                database_path: "test.db".to_string(),
2922                clickhouse_url: None,
2923                clickhouse_database: None,
2924                postgres_url: None,
2925                redis_url: None,
2926                auto_migrate: true,
2927            },
2928            timeout_ms: 60000,
2929            connection_timeout_ms: 10000,
2930            max_connections: 2000,
2931            enable_tls: true,
2932            tls_cert_file: Some("/etc/ssl/cert.pem".to_string()),
2933            tls_key_file: Some("/etc/ssl/key.pem".to_string()),
2934            enable_security_analysis: true,
2935            enable_trace_storage: true,
2936            enable_streaming: true,
2937            max_request_size_bytes: 100 * 1024 * 1024,
2938            security_analysis_timeout_ms: 3000,
2939            trace_storage_timeout_ms: 5000,
2940            rate_limiting: RateLimitConfig::default(),
2941            circuit_breaker: CircuitBreakerConfig::default(),
2942            health_check: HealthCheckConfig::default(),
2943            logging: LoggingConfig {
2944                level: "debug".to_string(),
2945                format: "json".to_string(),
2946            },
2947            cost_estimation: CostEstimationConfig {
2948                enabled: true,
2949                pricing_file: None,
2950                custom_models,
2951            },
2952            alerts: AlertConfig::default(),
2953            cost_caps: CostCapConfig::default(),
2954            security_analysis: SecurityAnalysisConfig {
2955                ml_enabled: true,
2956                ml_model: "custom/model".to_string(),
2957                ml_threshold: 0.9,
2958                ml_cache_dir: "/tmp/models".to_string(),
2959                ml_preload: true,
2960                ml_download_timeout_seconds: 300,
2961                ner_enabled: false,
2962                ner_model: default_ner_model(),
2963                fusion_enabled: false,
2964                fusion_model_path: None,
2965                jailbreak_enabled: true,
2966                jailbreak_threshold: 0.7,
2967                injecguard_enabled: false,
2968                injecguard_model: default_injecguard_model(),
2969                injecguard_threshold: default_injecguard_threshold(),
2970                piguard_enabled: false,
2971                piguard_model: default_piguard_model(),
2972                piguard_threshold: default_piguard_threshold(),
2973                operating_point: OperatingPoint::default(),
2974                over_defence: false,
2975            },
2976            otel_ingest: OtelIngestConfig::default(),
2977            auth: AuthConfig::default(),
2978            grpc: GrpcConfig::default(),
2979            anomaly_detection: AnomalyDetectionConfig::default(),
2980            streaming_analysis: StreamingAnalysisConfig::default(),
2981            pii: PiiConfig {
2982                action: PiiAction::AlertAndRedact,
2983            },
2984            output_safety: OutputSafetyConfig::default(),
2985            shutdown: ShutdownConfig::default(),
2986            enforcement: EnforcementConfig::default(),
2987        };
2988
2989        let serialized = serde_json::to_string(&config).unwrap();
2990        let deserialized: ProxyConfig = serde_json::from_str(&serialized).unwrap();
2991
2992        assert_eq!(config.listen_addr, deserialized.listen_addr);
2993        assert_eq!(config.upstream_url, deserialized.upstream_url);
2994        assert_eq!(config.enable_tls, deserialized.enable_tls);
2995        assert_eq!(config.tls_cert_file, deserialized.tls_cert_file);
2996        assert_eq!(config.storage.profile, deserialized.storage.profile);
2997        assert_eq!(
2998            config.storage.database_path,
2999            deserialized.storage.database_path
3000        );
3001        assert_eq!(config.logging.level, deserialized.logging.level);
3002        assert_eq!(config.logging.format, deserialized.logging.format);
3003        assert_eq!(deserialized.pii.action, PiiAction::AlertAndRedact);
3004        assert!(deserialized.cost_estimation.enabled);
3005        let custom = deserialized
3006            .cost_estimation
3007            .custom_models
3008            .get("my-custom-model")
3009            .unwrap();
3010        assert!((custom.input_per_million - 1.0).abs() < f64::EPSILON);
3011        assert!((custom.output_per_million - 2.0).abs() < f64::EPSILON);
3012        assert!(deserialized.security_analysis.ml_enabled);
3013        assert_eq!(deserialized.security_analysis.ml_model, "custom/model");
3014        assert!(!deserialized.grpc.enabled);
3015        assert_eq!(deserialized.grpc.listen_addr, "0.0.0.0:50051");
3016    }
3017
3018    #[test]
3019    fn test_trace_query_serialization() {
3020        let query = TraceQuery::new(TenantId::new())
3021            .with_time_range(Utc::now() - chrono::Duration::hours(1), Utc::now())
3022            .with_provider(LLMProvider::Anthropic)
3023            .with_limit(100);
3024
3025        let serialized = serde_json::to_string(&query).unwrap();
3026        let deserialized: TraceQuery = serde_json::from_str(&serialized).unwrap();
3027
3028        assert_eq!(query.tenant_id, deserialized.tenant_id);
3029        assert_eq!(query.provider, deserialized.provider);
3030        assert_eq!(query.limit, deserialized.limit);
3031    }
3032
3033    #[test]
3034    fn test_storage_stats_serialization() {
3035        let stats = StorageStats {
3036            total_traces: 12345,
3037            total_spans: 67890,
3038            storage_size_bytes: 1024 * 1024 * 1024, // 1GB
3039            oldest_trace: Some(Utc::now() - chrono::Duration::days(30)),
3040            newest_trace: Some(Utc::now()),
3041        };
3042
3043        let serialized = serde_json::to_string(&stats).unwrap();
3044        let deserialized: StorageStats = serde_json::from_str(&serialized).unwrap();
3045
3046        assert_eq!(stats.total_traces, deserialized.total_traces);
3047        assert_eq!(stats.total_spans, deserialized.total_spans);
3048        assert_eq!(stats.storage_size_bytes, deserialized.storage_size_bytes);
3049    }
3050
3051    #[test]
3052    fn test_tenant_creation() {
3053        let tenant = Tenant {
3054            id: TenantId::new(),
3055            name: "Acme Corp".to_string(),
3056            api_token: "test-token".to_string(),
3057            plan: "pro".to_string(),
3058            created_at: Utc::now(),
3059            config: serde_json::json!({"max_traces_per_day": 10000}),
3060        };
3061
3062        let serialized = serde_json::to_string(&tenant).unwrap();
3063        let deserialized: Tenant = serde_json::from_str(&serialized).unwrap();
3064        assert_eq!(tenant.id, deserialized.id);
3065        assert_eq!(tenant.name, deserialized.name);
3066        assert_eq!(tenant.plan, deserialized.plan);
3067    }
3068
3069    #[test]
3070    fn test_tenant_config_creation() {
3071        let mut thresholds = HashMap::new();
3072        thresholds.insert("alert_min_score".to_string(), 80.0);
3073
3074        let mut flags = HashMap::new();
3075        flags.insert("enable_pii_detection".to_string(), true);
3076
3077        let config = TenantConfig {
3078            tenant_id: TenantId::new(),
3079            security_thresholds: thresholds,
3080            feature_flags: flags,
3081            monitoring_scope: MonitoringScope::Hybrid,
3082            rate_limit_rpm: None,
3083            monthly_budget: None,
3084        };
3085
3086        let serialized = serde_json::to_string(&config).unwrap();
3087        let deserialized: TenantConfig = serde_json::from_str(&serialized).unwrap();
3088        assert_eq!(config.tenant_id, deserialized.tenant_id);
3089        assert_eq!(
3090            config.security_thresholds.get("alert_min_score"),
3091            deserialized.security_thresholds.get("alert_min_score")
3092        );
3093    }
3094
3095    #[test]
3096    fn test_audit_event_creation() {
3097        let event = AuditEvent {
3098            id: Uuid::new_v4(),
3099            tenant_id: TenantId::new(),
3100            event_type: "config_changed".to_string(),
3101            actor: "admin@example.com".to_string(),
3102            resource: "tenant_config".to_string(),
3103            data: serde_json::json!({"field": "enable_pii_detection", "old": false, "new": true}),
3104            timestamp: Utc::now(),
3105        };
3106
3107        let serialized = serde_json::to_string(&event).unwrap();
3108        let deserialized: AuditEvent = serde_json::from_str(&serialized).unwrap();
3109        assert_eq!(event.id, deserialized.id);
3110        assert_eq!(event.event_type, deserialized.event_type);
3111        assert_eq!(event.actor, deserialized.actor);
3112    }
3113
3114    #[test]
3115    fn test_audit_query_builder() {
3116        let tenant = TenantId::new();
3117        let start = Utc::now() - chrono::Duration::hours(1);
3118        let end = Utc::now();
3119
3120        let query = AuditQuery::new(tenant)
3121            .with_event_type("config_changed".to_string())
3122            .with_time_range(start, end)
3123            .with_limit(50);
3124
3125        assert_eq!(query.tenant_id, tenant);
3126        assert_eq!(query.event_type, Some("config_changed".to_string()));
3127        assert_eq!(query.start_time, Some(start));
3128        assert_eq!(query.end_time, Some(end));
3129        assert_eq!(query.limit, Some(50));
3130    }
3131
3132    #[test]
3133    fn test_alert_config_default() {
3134        let config = AlertConfig::default();
3135        assert!(!config.enabled);
3136        assert!(config.webhook_url.is_empty());
3137        assert_eq!(config.min_severity, "High");
3138        assert_eq!(config.min_security_score, 70);
3139        assert_eq!(config.cooldown_seconds, 300);
3140        assert!(config.channels.is_empty());
3141        assert!(config.escalation.is_none());
3142    }
3143
3144    #[test]
3145    fn test_alert_config_serialization() {
3146        let config = AlertConfig {
3147            enabled: true,
3148            webhook_url: "https://hooks.slack.com/services/T00/B00/xxx".to_string(),
3149            min_severity: "Critical".to_string(),
3150            min_security_score: 90,
3151            cooldown_seconds: 600,
3152            channels: Vec::new(),
3153            escalation: None,
3154        };
3155        let serialized = serde_json::to_string(&config).unwrap();
3156        let deserialized: AlertConfig = serde_json::from_str(&serialized).unwrap();
3157        assert_eq!(config.enabled, deserialized.enabled);
3158        assert_eq!(config.webhook_url, deserialized.webhook_url);
3159        assert_eq!(config.min_severity, deserialized.min_severity);
3160        assert_eq!(config.min_security_score, deserialized.min_security_score);
3161        assert_eq!(config.cooldown_seconds, deserialized.cooldown_seconds);
3162    }
3163
3164    #[test]
3165    fn test_alert_config_deserializes_with_defaults() {
3166        let json = r#"{"enabled": true, "webhook_url": "http://example.com"}"#;
3167        let config: AlertConfig = serde_json::from_str(json).unwrap();
3168        assert!(config.enabled);
3169        assert_eq!(config.webhook_url, "http://example.com");
3170        assert_eq!(config.min_severity, "High");
3171        assert_eq!(config.min_security_score, 70);
3172        assert_eq!(config.cooldown_seconds, 300);
3173        assert!(config.channels.is_empty());
3174    }
3175
3176    #[test]
3177    fn test_alert_config_multi_channel() {
3178        let json = r#"{
3179            "enabled": true,
3180            "cooldown_seconds": 120,
3181            "channels": [
3182                {"type": "slack", "url": "https://hooks.slack.com/services/T/B/x", "min_severity": "Medium"},
3183                {"type": "pagerduty", "routing_key": "abc123", "min_severity": "Critical"},
3184                {"type": "webhook", "url": "https://example.com/hook", "min_severity": "High"}
3185            ]
3186        }"#;
3187        let config: AlertConfig = serde_json::from_str(json).unwrap();
3188        assert!(config.enabled);
3189        assert_eq!(config.channels.len(), 3);
3190        assert_eq!(config.channels[0].channel_type, "slack");
3191        assert_eq!(config.channels[0].min_severity, "Medium");
3192        assert_eq!(config.channels[1].channel_type, "pagerduty");
3193        assert_eq!(config.channels[1].routing_key.as_deref(), Some("abc123"));
3194        assert_eq!(config.channels[2].channel_type, "webhook");
3195    }
3196
3197    #[test]
3198    fn test_alert_channel_config_effective_url() {
3199        // Prefers `url` over `webhook_url`
3200        let cfg = AlertChannelConfig {
3201            channel_type: "webhook".to_string(),
3202            url: Some("https://primary.com".to_string()),
3203            webhook_url: Some("https://fallback.com".to_string()),
3204            routing_key: None,
3205            min_severity: "High".to_string(),
3206            min_security_score: 70,
3207        };
3208        assert_eq!(cfg.effective_url(), Some("https://primary.com"));
3209
3210        // Falls back to `webhook_url`
3211        let cfg2 = AlertChannelConfig {
3212            url: None,
3213            webhook_url: Some("https://fallback.com".to_string()),
3214            ..cfg.clone()
3215        };
3216        assert_eq!(cfg2.effective_url(), Some("https://fallback.com"));
3217
3218        // Empty strings treated as None
3219        let cfg3 = AlertChannelConfig {
3220            url: Some(String::new()),
3221            webhook_url: None,
3222            ..cfg
3223        };
3224        assert!(cfg3.effective_url().is_none());
3225    }
3226
3227    #[test]
3228    fn test_proxy_config_default_includes_alerts() {
3229        let config = ProxyConfig::default();
3230        assert!(!config.alerts.enabled);
3231        assert!(config.alerts.webhook_url.is_empty());
3232        assert_eq!(config.alerts.min_severity, "High");
3233        assert!(config.alerts.channels.is_empty());
3234    }
3235
3236    #[test]
3237    fn test_security_severity_display() {
3238        assert_eq!(SecuritySeverity::Info.to_string(), "Info");
3239        assert_eq!(SecuritySeverity::Low.to_string(), "Low");
3240        assert_eq!(SecuritySeverity::Medium.to_string(), "Medium");
3241        assert_eq!(SecuritySeverity::High.to_string(), "High");
3242        assert_eq!(SecuritySeverity::Critical.to_string(), "Critical");
3243    }
3244
3245    #[test]
3246    fn test_security_severity_from_str() {
3247        assert_eq!(
3248            "Info".parse::<SecuritySeverity>().unwrap(),
3249            SecuritySeverity::Info
3250        );
3251        assert_eq!(
3252            "low".parse::<SecuritySeverity>().unwrap(),
3253            SecuritySeverity::Low
3254        );
3255        assert_eq!(
3256            "MEDIUM".parse::<SecuritySeverity>().unwrap(),
3257            SecuritySeverity::Medium
3258        );
3259        assert_eq!(
3260            "High".parse::<SecuritySeverity>().unwrap(),
3261            SecuritySeverity::High
3262        );
3263        assert_eq!(
3264            "critical".parse::<SecuritySeverity>().unwrap(),
3265            SecuritySeverity::Critical
3266        );
3267        assert!("unknown".parse::<SecuritySeverity>().is_err());
3268    }
3269
3270    // -- Cost cap types ---------------------------------------------------
3271
3272    #[test]
3273    fn test_budget_window_duration_secs() {
3274        assert_eq!(BudgetWindow::Hourly.duration_secs(), 3_600);
3275        assert_eq!(BudgetWindow::Daily.duration_secs(), 86_400);
3276        assert_eq!(BudgetWindow::Weekly.duration_secs(), 604_800);
3277        assert_eq!(BudgetWindow::Monthly.duration_secs(), 2_592_000);
3278    }
3279
3280    #[test]
3281    fn test_budget_window_cache_ttl_exceeds_window() {
3282        for window in [
3283            BudgetWindow::Hourly,
3284            BudgetWindow::Daily,
3285            BudgetWindow::Weekly,
3286            BudgetWindow::Monthly,
3287        ] {
3288            assert!(window.cache_ttl().as_secs() > window.duration_secs());
3289        }
3290    }
3291
3292    #[test]
3293    fn test_budget_window_display() {
3294        assert_eq!(BudgetWindow::Hourly.to_string(), "hourly");
3295        assert_eq!(BudgetWindow::Daily.to_string(), "daily");
3296        assert_eq!(BudgetWindow::Weekly.to_string(), "weekly");
3297        assert_eq!(BudgetWindow::Monthly.to_string(), "monthly");
3298    }
3299
3300    #[test]
3301    fn test_budget_window_serialization() {
3302        let window = BudgetWindow::Daily;
3303        let json = serde_json::to_string(&window).unwrap();
3304        assert_eq!(json, r#""daily""#);
3305        let deser: BudgetWindow = serde_json::from_str(&json).unwrap();
3306        assert_eq!(deser, window);
3307    }
3308
3309    #[test]
3310    fn test_budget_cap_serialization() {
3311        let cap = BudgetCap {
3312            window: BudgetWindow::Hourly,
3313            hard_limit_usd: 10.0,
3314            soft_limit_usd: Some(8.0),
3315        };
3316        let json = serde_json::to_string(&cap).unwrap();
3317        let deser: BudgetCap = serde_json::from_str(&json).unwrap();
3318        assert_eq!(deser.window, BudgetWindow::Hourly);
3319        assert!((deser.hard_limit_usd - 10.0).abs() < f64::EPSILON);
3320        assert!((deser.soft_limit_usd.unwrap() - 8.0).abs() < f64::EPSILON);
3321    }
3322
3323    #[test]
3324    fn test_budget_cap_no_soft_limit() {
3325        let json = r#"{"window":"monthly","hard_limit_usd":100.0}"#;
3326        let cap: BudgetCap = serde_json::from_str(json).unwrap();
3327        assert_eq!(cap.window, BudgetWindow::Monthly);
3328        assert!(cap.soft_limit_usd.is_none());
3329    }
3330
3331    #[test]
3332    fn test_token_cap_serialization() {
3333        let cap = TokenCap {
3334            max_prompt_tokens: Some(4096),
3335            max_completion_tokens: Some(2048),
3336            max_total_tokens: None,
3337        };
3338        let json = serde_json::to_string(&cap).unwrap();
3339        let deser: TokenCap = serde_json::from_str(&json).unwrap();
3340        assert_eq!(deser.max_prompt_tokens, Some(4096));
3341        assert_eq!(deser.max_completion_tokens, Some(2048));
3342        assert!(deser.max_total_tokens.is_none());
3343    }
3344
3345    #[test]
3346    fn test_agent_cost_cap_serialization() {
3347        let agent_cap = AgentCostCap {
3348            agent_id: "agent-007".to_string(),
3349            budget_caps: vec![BudgetCap {
3350                window: BudgetWindow::Daily,
3351                hard_limit_usd: 50.0,
3352                soft_limit_usd: Some(40.0),
3353            }],
3354            token_cap: Some(TokenCap {
3355                max_prompt_tokens: Some(8192),
3356                max_completion_tokens: None,
3357                max_total_tokens: Some(16384),
3358            }),
3359        };
3360        let json = serde_json::to_string(&agent_cap).unwrap();
3361        let deser: AgentCostCap = serde_json::from_str(&json).unwrap();
3362        assert_eq!(deser.agent_id, "agent-007");
3363        assert_eq!(deser.budget_caps.len(), 1);
3364        assert!(deser.token_cap.is_some());
3365    }
3366
3367    #[test]
3368    fn test_cost_cap_config_default() {
3369        let config = CostCapConfig::default();
3370        assert!(!config.enabled);
3371        assert!(config.default_budget_caps.is_empty());
3372        assert!(config.default_token_cap.is_none());
3373        assert!(config.agents.is_empty());
3374    }
3375
3376    #[test]
3377    fn test_cost_cap_config_serialization() {
3378        let config = CostCapConfig {
3379            enabled: true,
3380            default_budget_caps: vec![BudgetCap {
3381                window: BudgetWindow::Daily,
3382                hard_limit_usd: 100.0,
3383                soft_limit_usd: Some(80.0),
3384            }],
3385            default_token_cap: Some(TokenCap {
3386                max_prompt_tokens: Some(4096),
3387                max_completion_tokens: Some(4096),
3388                max_total_tokens: None,
3389            }),
3390            agents: vec![AgentCostCap {
3391                agent_id: "heavy-agent".to_string(),
3392                budget_caps: vec![BudgetCap {
3393                    window: BudgetWindow::Daily,
3394                    hard_limit_usd: 200.0,
3395                    soft_limit_usd: None,
3396                }],
3397                token_cap: None,
3398            }],
3399        };
3400        let json = serde_json::to_string(&config).unwrap();
3401        let deser: CostCapConfig = serde_json::from_str(&json).unwrap();
3402        assert!(deser.enabled);
3403        assert_eq!(deser.default_budget_caps.len(), 1);
3404        assert!(deser.default_token_cap.is_some());
3405        assert_eq!(deser.agents.len(), 1);
3406        assert_eq!(deser.agents[0].agent_id, "heavy-agent");
3407    }
3408
3409    #[test]
3410    fn test_proxy_config_default_includes_cost_caps() {
3411        let config = ProxyConfig::default();
3412        assert!(!config.cost_caps.enabled);
3413        assert!(config.cost_caps.default_budget_caps.is_empty());
3414    }
3415
3416    #[test]
3417    fn test_proxy_config_default_includes_security_analysis() {
3418        let config = ProxyConfig::default();
3419        assert!(config.security_analysis.ml_enabled);
3420        assert_eq!(
3421            config.security_analysis.ml_model,
3422            "protectai/deberta-v3-base-prompt-injection-v2"
3423        );
3424        assert!((config.security_analysis.ml_threshold - 0.8).abs() < f64::EPSILON);
3425        assert_eq!(
3426            config.security_analysis.ml_cache_dir,
3427            "~/.cache/llmtrace/models"
3428        );
3429        assert!(config.security_analysis.ml_preload);
3430        assert_eq!(config.security_analysis.ml_download_timeout_seconds, 300);
3431        assert!(!config.security_analysis.ner_enabled);
3432        assert_eq!(config.security_analysis.ner_model, "dslim/bert-base-NER");
3433        assert!(!config.security_analysis.fusion_enabled);
3434        assert!(config.security_analysis.fusion_model_path.is_none());
3435        assert!(config.security_analysis.jailbreak_enabled);
3436        assert!((config.security_analysis.jailbreak_threshold - 0.7).abs() < f32::EPSILON);
3437    }
3438
3439    #[test]
3440    fn test_security_analysis_config_default() {
3441        let config = SecurityAnalysisConfig::default();
3442        assert!(config.ml_enabled);
3443        assert_eq!(
3444            config.ml_model,
3445            "protectai/deberta-v3-base-prompt-injection-v2"
3446        );
3447        assert!((config.ml_threshold - 0.8).abs() < f64::EPSILON);
3448        assert_eq!(config.ml_cache_dir, "~/.cache/llmtrace/models");
3449        assert!(config.ml_preload);
3450        assert_eq!(config.ml_download_timeout_seconds, 300);
3451        assert!(!config.ner_enabled);
3452        assert_eq!(config.ner_model, "dslim/bert-base-NER");
3453        assert!(!config.fusion_enabled);
3454        assert!(config.fusion_model_path.is_none());
3455        assert!(config.jailbreak_enabled);
3456        assert!((config.jailbreak_threshold - 0.7).abs() < f32::EPSILON);
3457    }
3458
3459    #[test]
3460    fn test_security_analysis_config_serialization() {
3461        let config = SecurityAnalysisConfig {
3462            ml_enabled: true,
3463            ml_model: "custom/model".to_string(),
3464            ml_threshold: 0.9,
3465            ml_cache_dir: "/tmp/models".to_string(),
3466            ml_preload: false,
3467            ml_download_timeout_seconds: 600,
3468            ner_enabled: true,
3469            ner_model: "dslim/bert-base-NER".to_string(),
3470            fusion_enabled: false,
3471            fusion_model_path: None,
3472            jailbreak_enabled: true,
3473            jailbreak_threshold: 0.7,
3474            injecguard_enabled: false,
3475            injecguard_model: default_injecguard_model(),
3476            injecguard_threshold: default_injecguard_threshold(),
3477            piguard_enabled: false,
3478            piguard_model: default_piguard_model(),
3479            piguard_threshold: default_piguard_threshold(),
3480            operating_point: OperatingPoint::default(),
3481            over_defence: false,
3482        };
3483        let json = serde_json::to_string(&config).unwrap();
3484        let deserialized: SecurityAnalysisConfig = serde_json::from_str(&json).unwrap();
3485        assert!(deserialized.ml_enabled);
3486        assert_eq!(deserialized.ml_model, "custom/model");
3487        assert!((deserialized.ml_threshold - 0.9).abs() < f64::EPSILON);
3488        assert_eq!(deserialized.ml_cache_dir, "/tmp/models");
3489        assert!(!deserialized.ml_preload);
3490        assert_eq!(deserialized.ml_download_timeout_seconds, 600);
3491        assert!(deserialized.ner_enabled);
3492        assert_eq!(deserialized.ner_model, "dslim/bert-base-NER");
3493    }
3494
3495    // ---------------------------------------------------------------
3496    // Agent action types
3497    // ---------------------------------------------------------------
3498
3499    #[test]
3500    fn test_agent_action_type_display() {
3501        assert_eq!(AgentActionType::ToolCall.to_string(), "tool_call");
3502        assert_eq!(
3503            AgentActionType::SkillInvocation.to_string(),
3504            "skill_invocation"
3505        );
3506        assert_eq!(
3507            AgentActionType::CommandExecution.to_string(),
3508            "command_execution"
3509        );
3510        assert_eq!(AgentActionType::WebAccess.to_string(), "web_access");
3511        assert_eq!(AgentActionType::FileAccess.to_string(), "file_access");
3512    }
3513
3514    #[test]
3515    fn test_agent_action_type_serialization() {
3516        let types = vec![
3517            AgentActionType::ToolCall,
3518            AgentActionType::SkillInvocation,
3519            AgentActionType::CommandExecution,
3520            AgentActionType::WebAccess,
3521            AgentActionType::FileAccess,
3522        ];
3523        for action_type in types {
3524            let json = serde_json::to_string(&action_type).unwrap();
3525            let deser: AgentActionType = serde_json::from_str(&json).unwrap();
3526            assert_eq!(action_type, deser);
3527        }
3528    }
3529
3530    #[test]
3531    fn test_agent_action_creation() {
3532        let action = AgentAction::new(AgentActionType::ToolCall, "get_weather".to_string());
3533        assert_eq!(action.action_type, AgentActionType::ToolCall);
3534        assert_eq!(action.name, "get_weather");
3535        assert!(action.success);
3536        assert!(action.arguments.is_none());
3537        assert!(action.result.is_none());
3538    }
3539
3540    #[test]
3541    fn test_agent_action_builder() {
3542        let action = AgentAction::new(AgentActionType::CommandExecution, "ls -la".to_string())
3543            .with_arguments("-la /tmp".to_string())
3544            .with_result("file1\nfile2".to_string())
3545            .with_duration_ms(150)
3546            .with_exit_code(0)
3547            .with_metadata("cwd".to_string(), "/home".to_string());
3548
3549        assert_eq!(action.action_type, AgentActionType::CommandExecution);
3550        assert_eq!(action.arguments, Some("-la /tmp".to_string()));
3551        assert_eq!(action.result, Some("file1\nfile2".to_string()));
3552        assert_eq!(action.duration_ms, Some(150));
3553        assert_eq!(action.exit_code, Some(0));
3554        assert!(action.success);
3555        assert_eq!(action.metadata.get("cwd"), Some(&"/home".to_string()));
3556    }
3557
3558    #[test]
3559    fn test_agent_action_web_access() {
3560        let action = AgentAction::new(
3561            AgentActionType::WebAccess,
3562            "https://api.example.com".to_string(),
3563        )
3564        .with_http("GET".to_string(), 200)
3565        .with_duration_ms(500);
3566
3567        assert_eq!(action.http_method, Some("GET".to_string()));
3568        assert_eq!(action.http_status, Some(200));
3569    }
3570
3571    #[test]
3572    fn test_agent_action_file_access() {
3573        let action = AgentAction::new(AgentActionType::FileAccess, "/etc/passwd".to_string())
3574            .with_file_operation("read".to_string());
3575
3576        assert_eq!(action.file_operation, Some("read".to_string()));
3577    }
3578
3579    #[test]
3580    fn test_agent_action_failure() {
3581        let action = AgentAction::new(AgentActionType::CommandExecution, "rm -rf /".to_string())
3582            .with_failure()
3583            .with_exit_code(1);
3584
3585        assert!(!action.success);
3586        assert_eq!(action.exit_code, Some(1));
3587    }
3588
3589    #[test]
3590    fn test_agent_action_result_truncation() {
3591        let long_result = "x".repeat(AGENT_ACTION_RESULT_MAX_BYTES + 100);
3592        let action = AgentAction::new(AgentActionType::ToolCall, "big_output".to_string())
3593            .with_result(long_result);
3594
3595        assert_eq!(
3596            action.result.as_ref().unwrap().len(),
3597            AGENT_ACTION_RESULT_MAX_BYTES
3598        );
3599    }
3600
3601    #[test]
3602    fn test_agent_action_serialization() {
3603        let action = AgentAction::new(AgentActionType::ToolCall, "get_weather".to_string())
3604            .with_arguments(r#"{"location": "London"}"#.to_string())
3605            .with_result(r#"{"temp": 15}"#.to_string())
3606            .with_duration_ms(200);
3607
3608        let json = serde_json::to_string(&action).unwrap();
3609        let deser: AgentAction = serde_json::from_str(&json).unwrap();
3610
3611        assert_eq!(deser.action_type, AgentActionType::ToolCall);
3612        assert_eq!(deser.name, "get_weather");
3613        assert_eq!(deser.arguments, action.arguments);
3614        assert_eq!(deser.result, action.result);
3615        assert_eq!(deser.duration_ms, Some(200));
3616        assert!(deser.success);
3617    }
3618
3619    #[test]
3620    fn test_trace_span_agent_actions() {
3621        let mut span = TraceSpan::new(
3622            Uuid::new_v4(),
3623            TenantId::new(),
3624            "chat_completion".to_string(),
3625            LLMProvider::OpenAI,
3626            "gpt-4".to_string(),
3627            "test".to_string(),
3628        );
3629
3630        assert!(span.agent_actions.is_empty());
3631        assert!(!span.has_tool_calls());
3632        assert!(!span.has_web_access());
3633        assert!(!span.has_commands());
3634
3635        span.add_agent_action(AgentAction::new(
3636            AgentActionType::ToolCall,
3637            "search".to_string(),
3638        ));
3639        span.add_agent_action(AgentAction::new(
3640            AgentActionType::WebAccess,
3641            "https://example.com".to_string(),
3642        ));
3643        span.add_agent_action(AgentAction::new(
3644            AgentActionType::CommandExecution,
3645            "ls".to_string(),
3646        ));
3647        span.add_agent_action(AgentAction::new(
3648            AgentActionType::FileAccess,
3649            "/tmp/file.txt".to_string(),
3650        ));
3651
3652        assert_eq!(span.agent_actions.len(), 4);
3653        assert!(span.has_tool_calls());
3654        assert!(span.has_web_access());
3655        assert!(span.has_commands());
3656        assert_eq!(span.tool_calls().len(), 1);
3657        assert_eq!(span.web_accesses().len(), 1);
3658        assert_eq!(span.commands().len(), 1);
3659    }
3660
3661    #[test]
3662    fn test_trace_span_with_agent_actions_serialization() {
3663        let mut span = TraceSpan::new(
3664            Uuid::new_v4(),
3665            TenantId::new(),
3666            "chat_completion".to_string(),
3667            LLMProvider::OpenAI,
3668            "gpt-4".to_string(),
3669            "test prompt".to_string(),
3670        );
3671        span.add_agent_action(
3672            AgentAction::new(AgentActionType::ToolCall, "calculate".to_string())
3673                .with_arguments(r#"{"expr": "2+2"}"#.to_string())
3674                .with_result("4".to_string()),
3675        );
3676
3677        let json = serde_json::to_string(&span).unwrap();
3678        let deser: TraceSpan = serde_json::from_str(&json).unwrap();
3679
3680        assert_eq!(deser.agent_actions.len(), 1);
3681        assert_eq!(deser.agent_actions[0].name, "calculate");
3682        assert_eq!(
3683            deser.agent_actions[0].action_type,
3684            AgentActionType::ToolCall
3685        );
3686    }
3687
3688    #[test]
3689    fn test_trace_span_deserialize_without_agent_actions() {
3690        // Ensure backward compatibility: old spans without agent_actions field deserialize OK
3691        let json = r#"{
3692            "trace_id": "00000000-0000-0000-0000-000000000001",
3693            "span_id": "00000000-0000-0000-0000-000000000002",
3694            "parent_span_id": null,
3695            "tenant_id": "00000000-0000-0000-0000-000000000003",
3696            "operation_name": "test",
3697            "start_time": "2024-01-01T00:00:00Z",
3698            "end_time": null,
3699            "provider": "OpenAI",
3700            "model_name": "gpt-4",
3701            "prompt": "hello",
3702            "response": null,
3703            "prompt_tokens": null,
3704            "completion_tokens": null,
3705            "total_tokens": null,
3706            "time_to_first_token_ms": null,
3707            "duration_ms": null,
3708            "status_code": null,
3709            "error_message": null,
3710            "estimated_cost_usd": null,
3711            "security_score": null,
3712            "security_findings": [],
3713            "tags": {},
3714            "events": []
3715        }"#;
3716        let span: TraceSpan = serde_json::from_str(json).unwrap();
3717        assert!(span.agent_actions.is_empty());
3718    }
3719
3720    // -----------------------------------------------------------------------
3721    // Auth & RBAC types
3722    // -----------------------------------------------------------------------
3723
3724    #[test]
3725    fn test_api_key_role_has_permission() {
3726        // Admin can do everything
3727        assert!(ApiKeyRole::Admin.has_permission(ApiKeyRole::Admin));
3728        assert!(ApiKeyRole::Admin.has_permission(ApiKeyRole::Operator));
3729        assert!(ApiKeyRole::Admin.has_permission(ApiKeyRole::Viewer));
3730
3731        // Operator can do operator + viewer things
3732        assert!(!ApiKeyRole::Operator.has_permission(ApiKeyRole::Admin));
3733        assert!(ApiKeyRole::Operator.has_permission(ApiKeyRole::Operator));
3734        assert!(ApiKeyRole::Operator.has_permission(ApiKeyRole::Viewer));
3735
3736        // Viewer can only view
3737        assert!(!ApiKeyRole::Viewer.has_permission(ApiKeyRole::Admin));
3738        assert!(!ApiKeyRole::Viewer.has_permission(ApiKeyRole::Operator));
3739        assert!(ApiKeyRole::Viewer.has_permission(ApiKeyRole::Viewer));
3740    }
3741
3742    #[test]
3743    fn test_api_key_role_roundtrip() {
3744        for role in &[ApiKeyRole::Admin, ApiKeyRole::Operator, ApiKeyRole::Viewer] {
3745            let s = role.to_string();
3746            let parsed: ApiKeyRole = s.parse().unwrap();
3747            assert_eq!(*role, parsed);
3748        }
3749    }
3750
3751    #[test]
3752    fn test_api_key_role_serialization() {
3753        let role = ApiKeyRole::Operator;
3754        let json = serde_json::to_string(&role).unwrap();
3755        assert_eq!(json, r#""operator""#);
3756        let deserialized: ApiKeyRole = serde_json::from_str(&json).unwrap();
3757        assert_eq!(deserialized, ApiKeyRole::Operator);
3758    }
3759
3760    #[test]
3761    fn test_api_key_record_serialization_skips_hash() {
3762        let record = ApiKeyRecord {
3763            id: Uuid::new_v4(),
3764            tenant_id: TenantId::new(),
3765            name: "test-key".to_string(),
3766            key_hash: "abc123".to_string(),
3767            key_prefix: "llmt_ab…".to_string(),
3768            role: ApiKeyRole::Viewer,
3769            created_at: Utc::now(),
3770            revoked_at: None,
3771        };
3772        let json = serde_json::to_string(&record).unwrap();
3773        // key_hash is skip_serializing — should not appear in JSON output
3774        assert!(!json.contains("abc123"));
3775        assert!(json.contains("llmt_ab"));
3776    }
3777
3778    #[test]
3779    fn test_auth_config_default() {
3780        let cfg = AuthConfig::default();
3781        assert!(!cfg.enabled);
3782        assert!(cfg.admin_key.is_none());
3783    }
3784
3785    #[test]
3786    fn test_auth_config_serialization() {
3787        let cfg = AuthConfig {
3788            enabled: true,
3789            admin_key: Some("my-secret".to_string()),
3790        };
3791        let json = serde_json::to_string(&cfg).unwrap();
3792        let deser: AuthConfig = serde_json::from_str(&json).unwrap();
3793        assert!(deser.enabled);
3794        assert_eq!(deser.admin_key.as_deref(), Some("my-secret"));
3795    }
3796
3797    #[test]
3798    fn test_proxy_config_includes_auth() {
3799        let config = ProxyConfig::default();
3800        assert!(!config.auth.enabled);
3801        assert!(config.auth.admin_key.is_none());
3802    }
3803
3804    // -----------------------------------------------------------------------
3805    // PII types
3806    // -----------------------------------------------------------------------
3807
3808    #[test]
3809    fn test_pii_action_default() {
3810        assert_eq!(PiiAction::default(), PiiAction::AlertOnly);
3811    }
3812
3813    #[test]
3814    fn test_pii_action_display() {
3815        assert_eq!(PiiAction::AlertOnly.to_string(), "alert_only");
3816        assert_eq!(PiiAction::AlertAndRedact.to_string(), "alert_and_redact");
3817        assert_eq!(PiiAction::RedactSilent.to_string(), "redact_silent");
3818    }
3819
3820    #[test]
3821    fn test_pii_action_serialization() {
3822        let action = PiiAction::AlertAndRedact;
3823        let json = serde_json::to_string(&action).unwrap();
3824        assert_eq!(json, r#""alert_and_redact""#);
3825        let deser: PiiAction = serde_json::from_str(&json).unwrap();
3826        assert_eq!(deser, PiiAction::AlertAndRedact);
3827    }
3828
3829    #[test]
3830    fn test_pii_config_default() {
3831        let config = PiiConfig::default();
3832        assert_eq!(config.action, PiiAction::AlertOnly);
3833    }
3834
3835    #[test]
3836    fn test_pii_config_serialization() {
3837        let config = PiiConfig {
3838            action: PiiAction::RedactSilent,
3839        };
3840        let json = serde_json::to_string(&config).unwrap();
3841        let deser: PiiConfig = serde_json::from_str(&json).unwrap();
3842        assert_eq!(deser.action, PiiAction::RedactSilent);
3843    }
3844
3845    #[test]
3846    fn test_pii_config_deserializes_with_defaults() {
3847        let json = r#"{}"#;
3848        let config: PiiConfig = serde_json::from_str(json).unwrap();
3849        assert_eq!(config.action, PiiAction::AlertOnly);
3850    }
3851
3852    #[test]
3853    fn test_proxy_config_default_includes_pii() {
3854        let config = ProxyConfig::default();
3855        assert_eq!(config.pii.action, PiiAction::AlertOnly);
3856    }
3857}