1pub mod macros;
11pub mod sanctions;
12pub use sanctions::{MatchType, SanctionsHit, SanctionsSubject, SubjectType};
13
14use serde::{Deserialize, Serialize};
15use std::collections::HashMap;
16
17#[derive(Debug, Clone, Default)]
18pub struct CallContext {
19 pub correlation_id: Option<String>,
20 pub metadata: HashMap<String, String>,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct Observation<T> {
25 pub observation_id: String,
26 pub request_hash: String,
27 pub vendor: String,
28 pub model: String,
29 pub latency_ms: u64,
30 pub cost_estimate: Option<f64>,
31 pub tokens: Option<u64>,
32 pub content: T,
33 pub raw_response: Option<String>,
34}
35
36pub fn content_hash(input: &str) -> String {
37 use sha2::{Digest, Sha256};
38 let mut hasher = Sha256::new();
39 hasher.update(input.as_bytes());
40 let bytes = hasher.finalize();
41 format!(
42 "{:016x}",
43 u64::from_be_bytes(bytes[..8].try_into().expect("slice is 8 bytes"))
44 )
45}
46
47#[cfg(test)]
48mod tests {
49 use super::*;
50
51 #[test]
52 fn content_hash_is_deterministic() {
53 assert_eq!(content_hash("hello"), content_hash("hello"));
54 }
55
56 #[test]
57 fn content_hash_differs_for_distinct_inputs() {
58 assert_ne!(content_hash("hello"), content_hash("world"));
59 }
60
61 #[test]
62 fn content_hash_is_sixteen_hex_chars() {
63 let h = content_hash("anything");
64 assert_eq!(h.len(), 16);
65 assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
66 }
67
68 #[test]
69 fn call_context_default_is_empty() {
70 let ctx = CallContext::default();
71 assert!(ctx.correlation_id.is_none());
72 assert!(ctx.metadata.is_empty());
73 }
74
75 #[test]
76 fn call_context_carries_metadata() {
77 let mut ctx = CallContext {
78 correlation_id: Some("trace-1".to_string()),
79 metadata: HashMap::new(),
80 };
81 ctx.metadata.insert("k".into(), "v".into());
82 assert_eq!(ctx.correlation_id.as_deref(), Some("trace-1"));
83 assert_eq!(ctx.metadata.get("k").map(String::as_str), Some("v"));
84 }
85
86 #[test]
87 fn observation_round_trips_through_json() {
88 let obs = Observation {
89 observation_id: "obs:1".to_string(),
90 request_hash: content_hash("req"),
91 vendor: "vendor".to_string(),
92 model: "model".to_string(),
93 latency_ms: 42,
94 cost_estimate: Some(0.5),
95 tokens: Some(7),
96 content: 99u32,
97 raw_response: Some("{}".to_string()),
98 };
99 let json = serde_json::to_string(&obs).expect("serialize");
100 let back: Observation<u32> = serde_json::from_str(&json).expect("deserialize");
101 assert_eq!(back.observation_id, obs.observation_id);
102 assert_eq!(back.request_hash, obs.request_hash);
103 assert_eq!(back.vendor, obs.vendor);
104 assert_eq!(back.model, obs.model);
105 assert_eq!(back.latency_ms, obs.latency_ms);
106 assert_eq!(back.cost_estimate, obs.cost_estimate);
107 assert_eq!(back.tokens, obs.tokens);
108 assert_eq!(back.content, obs.content);
109 assert_eq!(back.raw_response, obs.raw_response);
110 }
111}