1use std::pin::Pin;
4use std::sync::Arc;
5
6use crate::language_models::{BaseLanguageModel, LLMResult, StreamChunk};
7use crate::runnables::Runnable;
8use crate::tools::ToolDefinition;
9use crate::{BaseChatModel, RunnableConfig};
10use async_trait::async_trait;
11use futures_util::{Stream, StreamExt};
12use lc_schema::Message;
13use tokio::sync::Mutex;
14
15use super::counter::{TokenCounter, TrackerTokenUsage};
16use super::tiktoken::TiktokenCounter;
17use super::TokenCounterError;
18
19pub struct TokenTrackingLLM<L: BaseChatModel> {
29 llm: L,
30 counter: Arc<dyn TokenCounter>,
31 usage: Arc<Mutex<TrackerTokenUsage>>,
32}
33
34impl<L: BaseChatModel> TokenTrackingLLM<L> {
35 pub fn new(llm: L, counter: Arc<dyn TokenCounter>) -> Self {
37 Self {
38 llm,
39 counter,
40 usage: Arc::new(Mutex::new(TrackerTokenUsage::new())),
41 }
42 }
43
44 pub fn for_openai(llm: L) -> Result<Self, TokenCounterError> {
46 let counter = TiktokenCounter::new()?;
47 Ok(Self::new(llm, Arc::new(counter)))
48 }
49
50 pub async fn chat(
57 &self,
58 messages: Vec<Message>,
59 config: Option<RunnableConfig>,
60 ) -> Result<LLMResult, L::Error> {
61 self.chat_tracked(messages, config).await
62 }
63
64 async fn chat_tracked(
66 &self,
67 messages: Vec<Message>,
68 config: Option<RunnableConfig>,
69 ) -> Result<LLMResult, L::Error> {
70 let estimated_prompt = self.counter.count_messages(&messages);
71 let result = self.llm.chat(messages, config).await?;
72
73 let (prompt, completion) = result
77 .token_usage
78 .as_ref()
79 .map(|u| (u.prompt_tokens, u.completion_tokens))
80 .unwrap_or((
81 estimated_prompt as usize,
82 self.counter.count_tokens(&result.content) as usize,
83 ));
84
85 self.usage.lock().await.add(prompt, completion);
86 Ok(result)
87 }
88
89 pub async fn get_usage(&self) -> TrackerTokenUsage {
91 self.usage.lock().await.clone()
92 }
93
94 pub async fn reset(&self) {
96 self.usage.lock().await.reset();
97 }
98
99 pub async fn estimate_cost(&self, pricing: &ModelPricing) -> f64 {
101 let usage = self.get_usage().await;
102 pricing.calculate(usage.prompt_tokens, usage.completion_tokens)
103 }
104}
105
106#[async_trait]
107impl<L> Runnable<Vec<Message>, LLMResult> for TokenTrackingLLM<L>
108where
109 L: BaseChatModel + Send + Sync,
110{
111 type Error = L::Error;
112
113 async fn invoke(
114 &self,
115 input: Vec<Message>,
116 config: Option<RunnableConfig>,
117 ) -> Result<LLMResult, Self::Error> {
118 self.chat_tracked(input, config).await
119 }
120
121 async fn stream(
122 &self,
123 input: Vec<Message>,
124 config: Option<RunnableConfig>,
125 ) -> Result<Pin<Box<dyn Stream<Item = Result<LLMResult, Self::Error>> + Send>>, Self::Error>
126 {
127 let model = self.llm.model_name().to_string();
132 let stream = self.stream_chat(input, config).await?;
133 let stream = stream.map(move |item| match item {
134 Ok(chunk) => Ok(LLMResult {
135 content: chunk.text,
136 model: model.clone(),
137 token_usage: chunk.token_usage,
138 tool_calls: chunk.tool_calls,
139 thinking_content: None,
140 }),
141 Err(e) => Err(e),
142 });
143 Ok(Box::pin(stream))
144 }
145}
146
147#[async_trait]
148impl<L> BaseLanguageModel<Vec<Message>, LLMResult> for TokenTrackingLLM<L>
149where
150 L: BaseChatModel + Send + Sync,
151{
152 fn model_name(&self) -> &str {
153 self.llm.model_name()
154 }
155
156 fn get_num_tokens(&self, text: &str) -> usize {
157 self.llm.get_num_tokens(text)
158 }
159
160 fn temperature(&self) -> Option<f32> {
161 self.llm.temperature()
162 }
163
164 fn max_tokens(&self) -> Option<usize> {
165 self.llm.max_tokens()
166 }
167
168 fn with_temperature(self, temp: f32) -> Self
169 where
170 Self: Sized,
171 {
172 Self {
175 llm: self.llm.with_temperature(temp),
176 counter: self.counter.clone(),
177 usage: self.usage.clone(),
178 }
179 }
180
181 fn with_max_tokens(self, max: usize) -> Self
182 where
183 Self: Sized,
184 {
185 Self {
186 llm: self.llm.with_max_tokens(max),
187 counter: self.counter.clone(),
188 usage: self.usage.clone(),
189 }
190 }
191}
192
193#[async_trait]
194impl<L> BaseChatModel for TokenTrackingLLM<L>
195where
196 L: BaseChatModel + Send + Sync,
197{
198 async fn chat(
199 &self,
200 messages: Vec<Message>,
201 config: Option<RunnableConfig>,
202 ) -> Result<LLMResult, Self::Error> {
203 self.chat_tracked(messages, config).await
204 }
205
206 async fn stream_chat(
207 &self,
208 messages: Vec<Message>,
209 config: Option<RunnableConfig>,
210 ) -> Result<Pin<Box<dyn Stream<Item = Result<StreamChunk, Self::Error>> + Send>>, Self::Error>
211 {
212 let stream = self.llm.stream_chat(messages, config).await?;
218 let usage = self.usage.clone();
219 let stream = stream.then(move |item| {
220 let usage = usage.clone();
221 async move {
222 if let Ok(chunk) = &item {
223 if let Some(u) = &chunk.token_usage {
224 usage.lock().await.add(u.prompt_tokens, u.completion_tokens);
225 }
226 }
227 item
228 }
229 });
230 Ok(Box::pin(stream))
231 }
232
233 fn bind_tools(
234 &self,
235 tools: Vec<ToolDefinition>,
236 ) -> Option<Box<dyn BaseChatModel<Error = Self::Error> + Send + Sync>> {
237 let bound = self.llm.bind_tools(tools)?;
242 Some(Box::new(TokenTrackingLLM {
243 llm: bound,
244 counter: self.counter.clone(),
245 usage: self.usage.clone(),
246 }))
247 }
248}
249
250pub struct ModelPricing {
252 pub prompt_price_per_1k: f64,
254 pub completion_price_per_1k: f64,
256}
257
258impl ModelPricing {
259 pub fn new(prompt: f64, completion: f64) -> Self {
261 Self {
262 prompt_price_per_1k: prompt,
263 completion_price_per_1k: completion,
264 }
265 }
266
267 pub fn gpt4o_mini() -> Self {
269 Self::new(0.15, 0.60)
270 }
271
272 pub fn gpt4o() -> Self {
274 Self::new(2.50, 10.00)
275 }
276
277 pub fn calculate(&self, prompt: usize, completion: usize) -> f64 {
279 (prompt as f64 / 1000.0) * self.prompt_price_per_1k
280 + (completion as f64 / 1000.0) * self.completion_price_per_1k
281 }
282}
283
284#[cfg(test)]
285mod tests {
286 use super::*;
287
288 #[test]
289 fn test_model_pricing_gpt4o_mini() {
290 let p = ModelPricing::gpt4o_mini();
291 let cost = p.calculate(1000, 1000);
293 assert!((cost - 0.75).abs() < 0.001);
294 }
295
296 #[test]
297 fn test_model_pricing_zero() {
298 let p = ModelPricing::gpt4o_mini();
299 assert_eq!(p.calculate(0, 0), 0.0);
300 }
301
302 #[test]
303 fn test_model_pricing_custom() {
304 let p = ModelPricing::new(1.0, 2.0);
305 let cost = p.calculate(500, 250);
307 assert!((cost - 1.0).abs() < 0.001);
308 }
309
310 use crate::language_models::TokenUsage;
315 use crate::token_counter::CharRatioCounter;
316
317 #[derive(Debug, Clone)]
319 struct MockChatModel {
320 chat_usage: Option<TokenUsage>,
322 stream_usage: Option<TokenUsage>,
324 tool_capable: bool,
326 }
327
328 #[derive(Debug)]
329 struct MockError;
330
331 impl std::fmt::Display for MockError {
332 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
333 write!(f, "mock error")
334 }
335 }
336
337 impl std::error::Error for MockError {}
338
339 #[async_trait]
340 impl Runnable<Vec<Message>, LLMResult> for MockChatModel {
341 type Error = MockError;
342
343 async fn invoke(
344 &self,
345 input: Vec<Message>,
346 config: Option<RunnableConfig>,
347 ) -> Result<LLMResult, Self::Error> {
348 self.chat(input, config).await
349 }
350 }
351
352 #[async_trait]
353 impl BaseLanguageModel<Vec<Message>, LLMResult> for MockChatModel {
354 fn model_name(&self) -> &str {
355 "mock-model"
356 }
357
358 fn get_num_tokens(&self, text: &str) -> usize {
359 text.len() / 4
360 }
361
362 fn with_temperature(self, _temp: f32) -> Self
363 where
364 Self: Sized,
365 {
366 self
367 }
368
369 fn with_max_tokens(self, _max: usize) -> Self
370 where
371 Self: Sized,
372 {
373 self
374 }
375 }
376
377 #[async_trait]
378 impl BaseChatModel for MockChatModel {
379 async fn chat(
380 &self,
381 _messages: Vec<Message>,
382 _config: Option<RunnableConfig>,
383 ) -> Result<LLMResult, Self::Error> {
384 Ok(LLMResult {
385 content: "mock reply".to_string(),
386 model: "mock-model".to_string(),
387 token_usage: self.chat_usage.clone(),
388 tool_calls: None,
389 thinking_content: None,
390 })
391 }
392
393 async fn stream_chat(
394 &self,
395 _messages: Vec<Message>,
396 _config: Option<RunnableConfig>,
397 ) -> Result<Pin<Box<dyn Stream<Item = Result<StreamChunk, Self::Error>> + Send>>, Self::Error>
398 {
399 let chunks = vec![
400 Ok(StreamChunk::new("hello")),
401 Ok(StreamChunk {
402 text: " world".to_string(),
403 token_usage: self.stream_usage.clone(),
404 tool_calls: None,
405 }),
406 ];
407 Ok(Box::pin(futures_util::stream::iter(chunks)))
408 }
409
410 fn bind_tools(
411 &self,
412 _tools: Vec<ToolDefinition>,
413 ) -> Option<Box<dyn BaseChatModel<Error = Self::Error> + Send + Sync>> {
414 self.tool_capable.then(|| {
415 Box::new(self.clone()) as Box<dyn BaseChatModel<Error = MockError> + Send + Sync>
416 })
417 }
418 }
419
420 fn tracked_mock(
421 chat_usage: Option<TokenUsage>,
422 tool_capable: bool,
423 ) -> TokenTrackingLLM<MockChatModel> {
424 TokenTrackingLLM::new(
425 MockChatModel {
426 chat_usage,
427 stream_usage: None,
428 tool_capable,
429 },
430 Arc::new(CharRatioCounter::new(4)),
431 )
432 }
433
434 #[tokio::test]
435 async fn chat_accumulates_real_usage_across_calls() {
436 let tracked = tracked_mock(
437 Some(TokenUsage {
438 prompt_tokens: 100,
439 completion_tokens: 20,
440 total_tokens: 120,
441 }),
442 false,
443 );
444 let msgs = vec![Message::human("hi")];
445 tracked.chat(msgs.clone(), None).await.unwrap();
446 tracked.chat(msgs, None).await.unwrap();
447
448 let usage = tracked.get_usage().await;
449 assert_eq!(usage.prompt_tokens, 200);
450 assert_eq!(usage.completion_tokens, 40);
451 assert_eq!(usage.total_tokens, 240);
452 }
453
454 #[tokio::test]
455 async fn chat_via_dyn_base_chat_model_counts() {
456 let tracked = tracked_mock(
459 Some(TokenUsage {
460 prompt_tokens: 7,
461 completion_tokens: 3,
462 total_tokens: 10,
463 }),
464 false,
465 );
466 let model: &dyn BaseChatModel<Error = MockError> = &tracked;
467 model.chat(vec![Message::human("hi")], None).await.unwrap();
468
469 let usage = tracked.get_usage().await;
470 assert_eq!(usage.prompt_tokens, 7);
471 assert_eq!(usage.completion_tokens, 3);
472 }
473
474 #[tokio::test]
475 async fn chat_estimates_when_provider_reports_no_usage() {
476 let tracked = tracked_mock(None, false);
477 tracked
478 .chat(vec![Message::human("hello world")], None)
479 .await
480 .unwrap();
481
482 let usage = tracked.get_usage().await;
483 assert!(usage.prompt_tokens > 0, "prompt should be estimated");
484 assert!(
485 usage.completion_tokens > 0,
486 "completion should be estimated"
487 );
488 }
489
490 #[tokio::test]
491 async fn stream_chat_accumulates_real_usage() {
492 let llm = MockChatModel {
493 chat_usage: None,
494 stream_usage: Some(TokenUsage {
495 prompt_tokens: 50,
496 completion_tokens: 15,
497 total_tokens: 65,
498 }),
499 tool_capable: false,
500 };
501 let tracked = TokenTrackingLLM::new(llm, Arc::new(CharRatioCounter::new(4)));
502
503 let stream = tracked
504 .stream_chat(vec![Message::human("hi")], None)
505 .await
506 .unwrap();
507 let items: Vec<_> = stream.collect().await;
508 assert_eq!(items.len(), 2);
509 assert!(items[0].is_ok());
510
511 let usage = tracked.get_usage().await;
512 assert_eq!(usage.prompt_tokens, 50);
513 assert_eq!(usage.completion_tokens, 15);
514 }
515
516 #[tokio::test]
517 async fn runnable_stream_counts_through_stream_chat() {
518 let llm = MockChatModel {
519 chat_usage: None,
520 stream_usage: Some(TokenUsage {
521 prompt_tokens: 5,
522 completion_tokens: 5,
523 total_tokens: 10,
524 }),
525 tool_capable: false,
526 };
527 let tracked = TokenTrackingLLM::new(llm, Arc::new(CharRatioCounter::new(4)));
528
529 let stream = tracked
530 .stream(vec![Message::human("hi")], None)
531 .await
532 .unwrap();
533 let items: Vec<_> = stream.collect().await;
534 assert_eq!(items.len(), 2);
535 assert!(items[0].is_ok());
536
537 let usage = tracked.get_usage().await;
538 assert_eq!(usage.total_tokens, 10);
539 }
540
541 #[tokio::test]
542 async fn bind_tools_keeps_shared_usage() {
543 let tracked = tracked_mock(
546 Some(TokenUsage {
547 prompt_tokens: 10,
548 completion_tokens: 4,
549 total_tokens: 14,
550 }),
551 true,
552 );
553
554 let bound = tracked
555 .bind_tools(vec![ToolDefinition::new(
556 "get_weather",
557 "Get current weather",
558 )])
559 .expect("tool-capable mock must bind");
560 bound.chat(vec![Message::human("hi")], None).await.unwrap();
561
562 let usage = tracked.get_usage().await;
563 assert_eq!(usage.prompt_tokens, 10);
564 assert_eq!(usage.completion_tokens, 4);
565 }
566
567 #[test]
568 fn bind_tools_returns_none_when_model_incapable() {
569 let tracked = tracked_mock(None, false);
570 assert!(tracked
571 .bind_tools(vec![ToolDefinition::new("t", "t")])
572 .is_none());
573 }
574
575 #[test]
576 fn base_model_metadata_passthrough() {
577 let tracked = tracked_mock(None, false);
578 assert_eq!(tracked.model_name(), "mock-model");
579 assert_eq!(tracked.get_num_tokens("hello world"), 2);
581 }
582
583 #[tokio::test]
584 async fn with_temperature_preserves_usage() {
585 let tracked = tracked_mock(
586 Some(TokenUsage {
587 prompt_tokens: 3,
588 completion_tokens: 1,
589 total_tokens: 4,
590 }),
591 false,
592 );
593 let tracked = tracked.with_temperature(0.5).with_max_tokens(128);
594 tracked
595 .chat(vec![Message::human("hi")], None)
596 .await
597 .unwrap();
598
599 let usage = tracked.get_usage().await;
600 assert_eq!(usage.prompt_tokens, 3);
601 assert_eq!(usage.completion_tokens, 1);
602 }
603}