lc_agents/hooks/
rate_limit.rs1use async_trait::async_trait;
10use lc_core::token_counter::{CharRatioCounter, TokenCounter};
11use lc_schema::Message;
12use std::sync::atomic::{AtomicUsize, Ordering};
13use std::sync::Arc;
14
15use super::{AgentHook, CompletionAction, CompletionContext, CompletionResult, HookError};
16
17pub struct TokenBudgetHook {
35 budget: usize,
37 max_calls: Option<usize>,
39 tokens_used: AtomicUsize,
41 calls: AtomicUsize,
43 counter: Option<Arc<dyn TokenCounter>>,
45}
46
47impl TokenBudgetHook {
48 pub fn new(budget: usize) -> Self {
50 Self {
51 budget,
52 max_calls: None,
53 tokens_used: AtomicUsize::new(0),
54 calls: AtomicUsize::new(0),
55 counter: None,
56 }
57 }
58
59 pub fn with_max_calls(mut self, max_calls: usize) -> Self {
61 self.max_calls = Some(max_calls);
62 self
63 }
64
65 pub fn with_counter(mut self, counter: Arc<dyn TokenCounter>) -> Self {
67 self.counter = Some(counter);
68 self
69 }
70
71 pub fn budget(&self) -> usize {
73 self.budget
74 }
75
76 pub fn tokens_used(&self) -> usize {
78 self.tokens_used.load(Ordering::SeqCst)
79 }
80
81 pub fn calls(&self) -> usize {
83 self.calls.load(Ordering::SeqCst)
84 }
85
86 pub fn remaining(&self) -> usize {
88 self.budget.saturating_sub(self.tokens_used())
89 }
90
91 fn estimate_messages(&self, messages: &[Message]) -> usize {
93 match &self.counter {
94 Some(c) => c.count_messages(messages) as usize,
95 None => CharRatioCounter::new(4).count_messages(messages) as usize,
96 }
97 }
98}
99
100#[async_trait]
101impl AgentHook for TokenBudgetHook {
102 fn on_before_completion(&self, ctx: &mut CompletionContext) -> CompletionAction {
103 if let Some(max) = self.max_calls {
105 if self.calls.load(Ordering::SeqCst) >= max {
106 return CompletionAction::Reject {
107 reason: format!("LLM call quota exceeded: max_calls={max}"),
108 };
109 }
110 }
111
112 let used = self.tokens_used.load(Ordering::SeqCst);
114 let estimate = self.estimate_messages(&ctx.messages);
115 if used.saturating_add(estimate) > self.budget {
116 return CompletionAction::Reject {
117 reason: format!(
118 "token budget exceeded: budget={}, used={used}, estimate={estimate}",
119 self.budget
120 ),
121 };
122 }
123
124 self.calls.fetch_add(1, Ordering::SeqCst);
125 CompletionAction::Continue
126 }
127
128 fn on_after_completion(&self, ctx: &mut CompletionResult) -> Result<(), HookError> {
129 if let Some(usage) = &ctx.tokens_used {
130 self.tokens_used
131 .fetch_add(usage.total_tokens, Ordering::SeqCst);
132 }
133 Ok(())
134 }
135}
136
137#[cfg(test)]
138mod tests {
139 use super::*;
140 use lc_core::language_models::TokenUsage;
141
142 fn completion_ctx(text: &str) -> CompletionContext {
143 CompletionContext {
144 messages: vec![Message::human(text.to_string())],
145 model: "mock".to_string(),
146 metadata: std::collections::HashMap::new(),
147 }
148 }
149
150 #[test]
151 fn test_allows_within_budget() {
152 let hook = TokenBudgetHook::new(1_000);
153 assert!(matches!(
154 hook.on_before_completion(&mut completion_ctx("short")),
155 CompletionAction::Continue
156 ));
157 assert_eq!(hook.calls(), 1);
158 }
159
160 #[test]
161 fn test_rejects_when_estimated_over_budget() {
162 let hook = TokenBudgetHook::new(0);
164 assert!(matches!(
165 hook.on_before_completion(&mut completion_ctx("x")),
166 CompletionAction::Reject { .. }
167 ));
168 }
169
170 #[test]
171 fn test_accumulates_real_usage_after_completion() {
172 let hook = TokenBudgetHook::new(1_000);
173 let mut result = CompletionResult {
174 message: Message::ai("hi"),
175 tokens_used: Some(TokenUsage {
176 prompt_tokens: 10,
177 completion_tokens: 20,
178 total_tokens: 30,
179 }),
180 };
181 hook.on_after_completion(&mut result).unwrap();
182 assert_eq!(hook.tokens_used(), 30);
183 assert_eq!(hook.remaining(), 970);
184 }
185
186 #[test]
187 fn test_max_calls_quota() {
188 let hook = TokenBudgetHook::new(1_000).with_max_calls(2);
189 assert!(matches!(
190 hook.on_before_completion(&mut completion_ctx("a")),
191 CompletionAction::Continue
192 ));
193 assert!(matches!(
194 hook.on_before_completion(&mut completion_ctx("b")),
195 CompletionAction::Continue
196 ));
197 let action = hook.on_before_completion(&mut completion_ctx("c"));
199 match action {
200 CompletionAction::Reject { reason } => assert!(reason.contains("quota"), "{reason}"),
201 other => panic!("expected Reject, got {:?}", other),
202 }
203 assert_eq!(hook.calls(), 2);
204 }
205
206 #[test]
207 fn test_rejects_after_real_usage_exceeds_budget() {
208 let hook = TokenBudgetHook::new(100);
209 let mut result = CompletionResult {
211 message: Message::ai("hi"),
212 tokens_used: Some(TokenUsage {
213 prompt_tokens: 0,
214 completion_tokens: 90,
215 total_tokens: 90,
216 }),
217 };
218 hook.on_after_completion(&mut result).unwrap();
219 let action = hook.on_before_completion(&mut completion_ctx("a long enough message"));
220 match action {
221 CompletionAction::Reject { reason } => {
222 assert!(reason.contains("budget"), "{reason}")
223 }
224 other => panic!("expected Reject, got {:?}", other),
225 }
226 }
227}