Skip to main content

harn_vm/llm/
trigger_predicate.rs

1use std::cell::RefCell;
2use std::collections::{BTreeMap, HashMap};
3use std::sync::{Mutex, OnceLock};
4use std::time::Duration;
5
6use serde::{Deserialize, Serialize};
7
8use super::api::{LlmRequestPayload, LlmResult};
9
10#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
11pub struct TriggerPredicateBudget {
12    pub max_cost_usd: Option<f64>,
13    pub tokens_max: Option<u64>,
14    pub timeout_ms: Option<u64>,
15}
16
17impl TriggerPredicateBudget {
18    pub fn timeout(&self) -> Option<Duration> {
19        self.timeout_ms.map(Duration::from_millis)
20    }
21}
22
23#[derive(Clone, Debug, Serialize, Deserialize)]
24pub(crate) struct PredicateCacheEntry {
25    pub request_hash: String,
26    pub(crate) result: LlmResult,
27}
28
29#[derive(Clone, Debug, Default)]
30pub struct PredicateEvaluationCapture {
31    pub entries: Vec<PredicateCacheEntry>,
32    pub total_tokens: u64,
33    pub total_cost_usd: f64,
34    pub cached: bool,
35    pub budget_exceeded: bool,
36}
37
38#[derive(Clone, Debug, Default)]
39struct PredicateEvaluationState {
40    budget: TriggerPredicateBudget,
41    replay_cache: HashMap<String, LlmResult>,
42    entries: BTreeMap<String, LlmResult>,
43    total_tokens: u64,
44    total_cost_usd: f64,
45    cached: bool,
46    budget_exceeded: bool,
47}
48
49thread_local! {
50    static ACTIVE_PREDICATE_EVALUATION: RefCell<Option<PredicateEvaluationState>> = const { RefCell::new(None) };
51}
52
53fn request_cache() -> &'static Mutex<HashMap<String, LlmResult>> {
54    static CACHE: OnceLock<Mutex<HashMap<String, LlmResult>>> = OnceLock::new();
55    CACHE.get_or_init(|| Mutex::new(HashMap::new()))
56}
57
58pub(crate) fn reset_trigger_predicate_state() {
59    ACTIVE_PREDICATE_EVALUATION.with(|slot| {
60        *slot.borrow_mut() = None;
61    });
62    if let Ok(mut cache) = request_cache().lock() {
63        cache.clear();
64    }
65}
66
67pub(crate) fn request_hash(request: &LlmRequestPayload) -> String {
68    use std::hash::{Hash, Hasher};
69
70    let canonical = serde_json::json!({
71        "provider": request.provider,
72        "model": request.model,
73        "messages": request.messages,
74        "system": request.system,
75        "max_tokens": request.max_tokens,
76        "temperature": request.temperature,
77        "top_p": request.top_p,
78        "top_k": request.top_k,
79        "logprobs": request.logprobs,
80        "logit_bias": request.logit_bias,
81        "min_p": request.min_p,
82        "repetition_penalty": request.repetition_penalty,
83        "prediction": request.prediction,
84        "verbosity": request.verbosity,
85        "mirostat": request.mirostat,
86        "stop": request.stop,
87        "seed": request.seed,
88        "frequency_penalty": request.frequency_penalty,
89        "presence_penalty": request.presence_penalty,
90        "parallel_tool_calls": request.parallel_tool_calls,
91        "output_format": request.output_format,
92        "thinking": request.thinking,
93        "anthropic_beta_features": request.anthropic_beta_features,
94        "native_tools": request.native_tools,
95        "tool_choice": request.tool_choice,
96        "cache": request.cache,
97        "timeout": request.timeout,
98        "stream": request.stream,
99        "provider_overrides": request.provider_overrides,
100        "prefill": request.prefill,
101        "mock_scope": request.mock_scope,
102    });
103    let mut hasher = std::collections::hash_map::DefaultHasher::new();
104    crate::canonical_json::to_string(&canonical).hash(&mut hasher);
105    format!("{:016x}", hasher.finish())
106}
107
108pub(crate) struct PredicateEvaluationGuard;
109
110impl PredicateEvaluationGuard {
111    pub fn finish(self) -> PredicateEvaluationCapture {
112        finish_predicate_evaluation()
113    }
114}
115
116impl Drop for PredicateEvaluationGuard {
117    fn drop(&mut self) {
118        ACTIVE_PREDICATE_EVALUATION.with(|slot| {
119            *slot.borrow_mut() = None;
120        });
121    }
122}
123
124pub(crate) fn start_predicate_evaluation(
125    budget: TriggerPredicateBudget,
126    replay_entries: Vec<PredicateCacheEntry>,
127) -> PredicateEvaluationGuard {
128    ACTIVE_PREDICATE_EVALUATION.with(|slot| {
129        *slot.borrow_mut() = Some(PredicateEvaluationState {
130            budget,
131            replay_cache: replay_entries
132                .into_iter()
133                .map(|entry| (entry.request_hash, entry.result))
134                .collect(),
135            ..Default::default()
136        });
137    });
138    PredicateEvaluationGuard
139}
140
141fn finish_predicate_evaluation() -> PredicateEvaluationCapture {
142    ACTIVE_PREDICATE_EVALUATION.with(|slot| {
143        let Some(state) = slot.borrow_mut().take() else {
144            return PredicateEvaluationCapture::default();
145        };
146        PredicateEvaluationCapture {
147            entries: state
148                .entries
149                .into_iter()
150                .map(|(request_hash, result)| PredicateCacheEntry {
151                    request_hash,
152                    result,
153                })
154                .collect(),
155            total_tokens: state.total_tokens,
156            total_cost_usd: state.total_cost_usd,
157            cached: state.cached,
158            budget_exceeded: state.budget_exceeded,
159        }
160    })
161}
162
163pub(crate) fn lookup_cached_result(request: &LlmRequestPayload) -> Option<LlmResult> {
164    ACTIVE_PREDICATE_EVALUATION.with(|slot| {
165        let mut borrowed = slot.borrow_mut();
166        let state = borrowed.as_mut()?;
167        if state.budget_exceeded {
168            return None;
169        }
170        let hash = request_hash(request);
171        let cached = state.replay_cache.get(&hash).cloned().or_else(|| {
172            request_cache()
173                .lock()
174                .ok()
175                .and_then(|cache| cache.get(&hash).cloned())
176        });
177        if let Some(result) = cached {
178            state.cached = true;
179            state.entries.insert(hash, result.clone());
180            return Some(result);
181        }
182        None
183    })
184}
185
186pub(crate) fn note_result(request: &LlmRequestPayload, result: &LlmResult) {
187    ACTIVE_PREDICATE_EVALUATION.with(|slot| {
188        let mut borrowed = slot.borrow_mut();
189        let Some(state) = borrowed.as_mut() else {
190            return;
191        };
192        let hash = request_hash(request);
193        state.entries.insert(hash.clone(), result.clone());
194        if let Ok(mut cache) = request_cache().lock() {
195            cache.insert(hash, result.clone());
196        }
197        let usage = result.usage();
198        let call_tokens = usage
199            .input_tokens
200            .saturating_add(usage.output_tokens)
201            .max(0) as u64;
202        state.total_tokens = state.total_tokens.saturating_add(call_tokens);
203        state.total_cost_usd += usage.cost_usd.unwrap_or(0.0);
204        if state
205            .budget
206            .tokens_max
207            .is_some_and(|limit| state.total_tokens > limit)
208        {
209            state.budget_exceeded = true;
210        }
211        if state
212            .budget
213            .max_cost_usd
214            .is_some_and(|limit| state.total_cost_usd > limit)
215        {
216            state.budget_exceeded = true;
217        }
218    });
219}