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