1use std::pin::Pin;
4use std::sync::Arc;
5
6use crate::language_models::{BaseLanguageModel, LLMResult, StreamChunk, TokenUsage};
7use crate::observability::{MetricsSink, ObsEvent};
8use crate::runnables::Runnable;
9use crate::tools::ToolDefinition;
10use crate::{BaseChatModel, RunnableConfig};
11use async_trait::async_trait;
12use futures_util::{Stream, StreamExt};
13use lc_schema::Message;
14use tokio::sync::Mutex;
15
16use super::counter::{TokenCounter, TrackerTokenUsage};
17use super::tiktoken::TiktokenCounter;
18use super::TokenCounterError;
19
20pub struct TokenTrackingLLM<L: BaseChatModel> {
30 llm: L,
31 counter: Arc<dyn TokenCounter>,
32 usage: Arc<Mutex<TrackerTokenUsage>>,
33 metrics_sink: Option<Arc<dyn MetricsSink>>,
36}
37
38impl<L: BaseChatModel> TokenTrackingLLM<L> {
39 pub fn new(llm: L, counter: Arc<dyn TokenCounter>) -> Self {
41 Self {
42 llm,
43 counter,
44 usage: Arc::new(Mutex::new(TrackerTokenUsage::new())),
45 metrics_sink: None,
46 }
47 }
48
49 pub fn for_openai(llm: L) -> Result<Self, TokenCounterError> {
51 let counter = TiktokenCounter::new()?;
52 Ok(Self::new(llm, Arc::new(counter)))
53 }
54
55 pub fn with_metrics_sink(mut self, sink: Arc<dyn MetricsSink>) -> Self {
60 self.metrics_sink = Some(sink);
61 self
62 }
63
64 pub async fn chat(
71 &self,
72 messages: Vec<Message>,
73 config: Option<RunnableConfig>,
74 ) -> Result<LLMResult, L::Error> {
75 self.chat_tracked(messages, config).await
76 }
77
78 async fn chat_tracked(
80 &self,
81 messages: Vec<Message>,
82 config: Option<RunnableConfig>,
83 ) -> Result<LLMResult, L::Error> {
84 let estimated_prompt = self.counter.count_messages(&messages);
85 let result = self.llm.chat(messages, config).await?;
86
87 let (prompt, completion) = result
91 .token_usage
92 .as_ref()
93 .map(|u| (u.prompt_tokens, u.completion_tokens))
94 .unwrap_or((
95 estimated_prompt as usize,
96 self.counter.count_tokens(&result.content) as usize,
97 ));
98
99 self.usage.lock().await.add(prompt, completion);
100
101 if let Some(sink) = &self.metrics_sink {
104 let evt = ObsEvent::TokenUsage(TokenUsage {
105 prompt_tokens: prompt,
106 completion_tokens: completion,
107 total_tokens: result
108 .token_usage
109 .as_ref()
110 .map(|u| u.total_tokens)
111 .unwrap_or(prompt + completion),
112 });
113 if let Err(e) = sink.export(&evt).await {
114 log::warn!(target: "lc_core::token_counter", "token usage export failed: {e}");
115 }
116 }
117
118 Ok(result)
119 }
120
121 pub async fn get_usage(&self) -> TrackerTokenUsage {
123 self.usage.lock().await.clone()
124 }
125
126 pub async fn reset(&self) {
128 self.usage.lock().await.reset();
129 }
130
131 pub async fn estimate_cost(&self, pricing: &ModelPricing) -> f64 {
133 let usage = self.get_usage().await;
134 pricing.calculate(usage.prompt_tokens, usage.completion_tokens)
135 }
136}
137
138#[async_trait]
139impl<L> Runnable<Vec<Message>, LLMResult> for TokenTrackingLLM<L>
140where
141 L: BaseChatModel + Send + Sync,
142{
143 type Error = L::Error;
144
145 async fn invoke(
146 &self,
147 input: Vec<Message>,
148 config: Option<RunnableConfig>,
149 ) -> Result<LLMResult, Self::Error> {
150 self.chat_tracked(input, config).await
151 }
152
153 async fn stream(
154 &self,
155 input: Vec<Message>,
156 config: Option<RunnableConfig>,
157 ) -> Result<Pin<Box<dyn Stream<Item = Result<LLMResult, Self::Error>> + Send>>, Self::Error>
158 {
159 let model = self.llm.model_name().to_string();
164 let stream = self.stream_chat(input, config).await?;
165 let stream = stream.map(move |item| match item {
166 Ok(chunk) => Ok(LLMResult {
167 content: chunk.text,
168 model: model.clone(),
169 token_usage: chunk.token_usage,
170 tool_calls: chunk.tool_calls,
171 thinking_content: None,
172 }),
173 Err(e) => Err(e),
174 });
175 Ok(Box::pin(stream))
176 }
177}
178
179#[async_trait]
180impl<L> BaseLanguageModel<Vec<Message>, LLMResult> for TokenTrackingLLM<L>
181where
182 L: BaseChatModel + Send + Sync,
183{
184 fn model_name(&self) -> &str {
185 self.llm.model_name()
186 }
187
188 fn get_num_tokens(&self, text: &str) -> usize {
189 self.llm.get_num_tokens(text)
190 }
191
192 fn temperature(&self) -> Option<f32> {
193 self.llm.temperature()
194 }
195
196 fn max_tokens(&self) -> Option<usize> {
197 self.llm.max_tokens()
198 }
199
200 fn with_temperature(self, temp: f32) -> Self
201 where
202 Self: Sized,
203 {
204 Self {
207 llm: self.llm.with_temperature(temp),
208 counter: self.counter.clone(),
209 usage: self.usage.clone(),
210 metrics_sink: self.metrics_sink.clone(),
211 }
212 }
213
214 fn with_max_tokens(self, max: usize) -> Self
215 where
216 Self: Sized,
217 {
218 Self {
219 llm: self.llm.with_max_tokens(max),
220 counter: self.counter.clone(),
221 usage: self.usage.clone(),
222 metrics_sink: self.metrics_sink.clone(),
223 }
224 }
225}
226
227#[async_trait]
228impl<L> BaseChatModel for TokenTrackingLLM<L>
229where
230 L: BaseChatModel + Send + Sync,
231{
232 async fn chat(
233 &self,
234 messages: Vec<Message>,
235 config: Option<RunnableConfig>,
236 ) -> Result<LLMResult, Self::Error> {
237 self.chat_tracked(messages, config).await
238 }
239
240 async fn stream_chat(
241 &self,
242 messages: Vec<Message>,
243 config: Option<RunnableConfig>,
244 ) -> Result<Pin<Box<dyn Stream<Item = Result<StreamChunk, Self::Error>> + Send>>, Self::Error>
245 {
246 let stream = self.llm.stream_chat(messages, config).await?;
252 let usage = self.usage.clone();
253 let sink = self.metrics_sink.clone();
254 let stream = stream.then(move |item| {
255 let usage = usage.clone();
256 let sink = sink.clone();
257 async move {
258 if let Ok(chunk) = &item {
259 if let Some(u) = &chunk.token_usage {
260 usage.lock().await.add(u.prompt_tokens, u.completion_tokens);
261 if let Some(sink) = &sink {
262 let evt = ObsEvent::TokenUsage(u.clone());
263 if let Err(e) = sink.export(&evt).await {
264 log::warn!(target: "lc_core::token_counter", "token usage export failed: {e}");
265 }
266 }
267 }
268 }
269 item
270 }
271 });
272 Ok(Box::pin(stream))
273 }
274
275 fn bind_tools(
276 &self,
277 tools: Vec<ToolDefinition>,
278 ) -> Option<Box<dyn BaseChatModel<Error = Self::Error> + Send + Sync>> {
279 let bound = self.llm.bind_tools(tools)?;
284 Some(Box::new(TokenTrackingLLM {
285 llm: bound,
286 counter: self.counter.clone(),
287 usage: self.usage.clone(),
288 metrics_sink: self.metrics_sink.clone(),
289 }))
290 }
291}
292
293pub struct ModelPricing {
295 pub prompt_price_per_1k: f64,
297 pub completion_price_per_1k: f64,
299}
300
301impl ModelPricing {
302 pub fn new(prompt: f64, completion: f64) -> Self {
304 Self {
305 prompt_price_per_1k: prompt,
306 completion_price_per_1k: completion,
307 }
308 }
309
310 pub fn gpt4o_mini() -> Self {
312 Self::new(0.15, 0.60)
313 }
314
315 pub fn gpt4o() -> Self {
317 Self::new(2.50, 10.00)
318 }
319
320 pub fn calculate(&self, prompt: usize, completion: usize) -> f64 {
322 (prompt as f64 / 1000.0) * self.prompt_price_per_1k
323 + (completion as f64 / 1000.0) * self.completion_price_per_1k
324 }
325}
326
327#[cfg(test)]
328mod tests {
329 use super::*;
330
331 #[test]
332 fn test_model_pricing_gpt4o_mini() {
333 let p = ModelPricing::gpt4o_mini();
334 let cost = p.calculate(1000, 1000);
336 assert!((cost - 0.75).abs() < 0.001);
337 }
338
339 #[test]
340 fn test_model_pricing_zero() {
341 let p = ModelPricing::gpt4o_mini();
342 assert_eq!(p.calculate(0, 0), 0.0);
343 }
344
345 #[test]
346 fn test_model_pricing_custom() {
347 let p = ModelPricing::new(1.0, 2.0);
348 let cost = p.calculate(500, 250);
350 assert!((cost - 1.0).abs() < 0.001);
351 }
352
353 use crate::language_models::TokenUsage;
358 use crate::observability::ObsError;
359 use crate::token_counter::CharRatioCounter;
360
361 #[derive(Debug, Clone)]
363 struct MockChatModel {
364 chat_usage: Option<TokenUsage>,
366 stream_usage: Option<TokenUsage>,
368 tool_capable: bool,
370 }
371
372 #[derive(Debug)]
373 struct MockError;
374
375 impl std::fmt::Display for MockError {
376 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
377 write!(f, "mock error")
378 }
379 }
380
381 impl std::error::Error for MockError {}
382
383 #[async_trait]
384 impl Runnable<Vec<Message>, LLMResult> for MockChatModel {
385 type Error = MockError;
386
387 async fn invoke(
388 &self,
389 input: Vec<Message>,
390 config: Option<RunnableConfig>,
391 ) -> Result<LLMResult, Self::Error> {
392 self.chat(input, config).await
393 }
394 }
395
396 #[async_trait]
397 impl BaseLanguageModel<Vec<Message>, LLMResult> for MockChatModel {
398 fn model_name(&self) -> &str {
399 "mock-model"
400 }
401
402 fn get_num_tokens(&self, text: &str) -> usize {
403 text.len() / 4
404 }
405
406 fn with_temperature(self, _temp: f32) -> Self
407 where
408 Self: Sized,
409 {
410 self
411 }
412
413 fn with_max_tokens(self, _max: usize) -> Self
414 where
415 Self: Sized,
416 {
417 self
418 }
419 }
420
421 #[async_trait]
422 impl BaseChatModel for MockChatModel {
423 async fn chat(
424 &self,
425 _messages: Vec<Message>,
426 _config: Option<RunnableConfig>,
427 ) -> Result<LLMResult, Self::Error> {
428 Ok(LLMResult {
429 content: "mock reply".to_string(),
430 model: "mock-model".to_string(),
431 token_usage: self.chat_usage.clone(),
432 tool_calls: None,
433 thinking_content: None,
434 })
435 }
436
437 async fn stream_chat(
438 &self,
439 _messages: Vec<Message>,
440 _config: Option<RunnableConfig>,
441 ) -> Result<Pin<Box<dyn Stream<Item = Result<StreamChunk, Self::Error>> + Send>>, Self::Error>
442 {
443 let chunks = vec![
444 Ok(StreamChunk::new("hello")),
445 Ok(StreamChunk {
446 text: " world".to_string(),
447 token_usage: self.stream_usage.clone(),
448 tool_calls: None,
449 }),
450 ];
451 Ok(Box::pin(futures_util::stream::iter(chunks)))
452 }
453
454 fn bind_tools(
455 &self,
456 _tools: Vec<ToolDefinition>,
457 ) -> Option<Box<dyn BaseChatModel<Error = Self::Error> + Send + Sync>> {
458 self.tool_capable.then(|| {
459 Box::new(self.clone()) as Box<dyn BaseChatModel<Error = MockError> + Send + Sync>
460 })
461 }
462 }
463
464 fn tracked_mock(
465 chat_usage: Option<TokenUsage>,
466 tool_capable: bool,
467 ) -> TokenTrackingLLM<MockChatModel> {
468 TokenTrackingLLM::new(
469 MockChatModel {
470 chat_usage,
471 stream_usage: None,
472 tool_capable,
473 },
474 Arc::new(CharRatioCounter::new(4)),
475 )
476 }
477
478 #[tokio::test]
479 async fn chat_accumulates_real_usage_across_calls() {
480 let tracked = tracked_mock(
481 Some(TokenUsage {
482 prompt_tokens: 100,
483 completion_tokens: 20,
484 total_tokens: 120,
485 }),
486 false,
487 );
488 let msgs = vec![Message::human("hi")];
489 tracked.chat(msgs.clone(), None).await.unwrap();
490 tracked.chat(msgs, None).await.unwrap();
491
492 let usage = tracked.get_usage().await;
493 assert_eq!(usage.prompt_tokens, 200);
494 assert_eq!(usage.completion_tokens, 40);
495 assert_eq!(usage.total_tokens, 240);
496 }
497
498 #[tokio::test]
499 async fn chat_via_dyn_base_chat_model_counts() {
500 let tracked = tracked_mock(
503 Some(TokenUsage {
504 prompt_tokens: 7,
505 completion_tokens: 3,
506 total_tokens: 10,
507 }),
508 false,
509 );
510 let model: &dyn BaseChatModel<Error = MockError> = &tracked;
511 model.chat(vec![Message::human("hi")], None).await.unwrap();
512
513 let usage = tracked.get_usage().await;
514 assert_eq!(usage.prompt_tokens, 7);
515 assert_eq!(usage.completion_tokens, 3);
516 }
517
518 #[tokio::test]
519 async fn chat_estimates_when_provider_reports_no_usage() {
520 let tracked = tracked_mock(None, false);
521 tracked
522 .chat(vec![Message::human("hello world")], None)
523 .await
524 .unwrap();
525
526 let usage = tracked.get_usage().await;
527 assert!(usage.prompt_tokens > 0, "prompt should be estimated");
528 assert!(
529 usage.completion_tokens > 0,
530 "completion should be estimated"
531 );
532 }
533
534 #[tokio::test]
535 async fn stream_chat_accumulates_real_usage() {
536 let llm = MockChatModel {
537 chat_usage: None,
538 stream_usage: Some(TokenUsage {
539 prompt_tokens: 50,
540 completion_tokens: 15,
541 total_tokens: 65,
542 }),
543 tool_capable: false,
544 };
545 let tracked = TokenTrackingLLM::new(llm, Arc::new(CharRatioCounter::new(4)));
546
547 let stream = tracked
548 .stream_chat(vec![Message::human("hi")], None)
549 .await
550 .unwrap();
551 let items: Vec<_> = stream.collect().await;
552 assert_eq!(items.len(), 2);
553 assert!(items[0].is_ok());
554
555 let usage = tracked.get_usage().await;
556 assert_eq!(usage.prompt_tokens, 50);
557 assert_eq!(usage.completion_tokens, 15);
558 }
559
560 #[tokio::test]
561 async fn runnable_stream_counts_through_stream_chat() {
562 let llm = MockChatModel {
563 chat_usage: None,
564 stream_usage: Some(TokenUsage {
565 prompt_tokens: 5,
566 completion_tokens: 5,
567 total_tokens: 10,
568 }),
569 tool_capable: false,
570 };
571 let tracked = TokenTrackingLLM::new(llm, Arc::new(CharRatioCounter::new(4)));
572
573 let stream = tracked
574 .stream(vec![Message::human("hi")], None)
575 .await
576 .unwrap();
577 let items: Vec<_> = stream.collect().await;
578 assert_eq!(items.len(), 2);
579 assert!(items[0].is_ok());
580
581 let usage = tracked.get_usage().await;
582 assert_eq!(usage.total_tokens, 10);
583 }
584
585 #[tokio::test]
586 async fn bind_tools_keeps_shared_usage() {
587 let tracked = tracked_mock(
590 Some(TokenUsage {
591 prompt_tokens: 10,
592 completion_tokens: 4,
593 total_tokens: 14,
594 }),
595 true,
596 );
597
598 let bound = tracked
599 .bind_tools(vec![ToolDefinition::new(
600 "get_weather",
601 "Get current weather",
602 )])
603 .expect("tool-capable mock must bind");
604 bound.chat(vec![Message::human("hi")], None).await.unwrap();
605
606 let usage = tracked.get_usage().await;
607 assert_eq!(usage.prompt_tokens, 10);
608 assert_eq!(usage.completion_tokens, 4);
609 }
610
611 #[test]
612 fn bind_tools_returns_none_when_model_incapable() {
613 let tracked = tracked_mock(None, false);
614 assert!(tracked
615 .bind_tools(vec![ToolDefinition::new("t", "t")])
616 .is_none());
617 }
618
619 #[test]
620 fn base_model_metadata_passthrough() {
621 let tracked = tracked_mock(None, false);
622 assert_eq!(tracked.model_name(), "mock-model");
623 assert_eq!(tracked.get_num_tokens("hello world"), 2);
625 }
626
627 #[tokio::test]
628 async fn with_temperature_preserves_usage() {
629 let tracked = tracked_mock(
630 Some(TokenUsage {
631 prompt_tokens: 3,
632 completion_tokens: 1,
633 total_tokens: 4,
634 }),
635 false,
636 );
637 let tracked = tracked.with_temperature(0.5).with_max_tokens(128);
638 tracked
639 .chat(vec![Message::human("hi")], None)
640 .await
641 .unwrap();
642
643 let usage = tracked.get_usage().await;
644 assert_eq!(usage.prompt_tokens, 3);
645 assert_eq!(usage.completion_tokens, 1);
646 }
647
648 struct MockSink {
652 events: Arc<Mutex<Vec<ObsEvent>>>,
653 fail: bool,
654 }
655
656 #[async_trait]
657 impl MetricsSink for MockSink {
658 async fn export(&self, event: &ObsEvent) -> Result<(), ObsError> {
659 if self.fail {
660 return Err(ObsError::Transport("mock failure".to_string()));
661 }
662 self.events.lock().await.push(event.clone());
663 Ok(())
664 }
665 }
666
667 fn tracked_mock_with_sink(
668 tool_capable: bool,
669 events: Arc<Mutex<Vec<ObsEvent>>>,
670 ) -> (TokenTrackingLLM<MockChatModel>, Arc<Mutex<Vec<ObsEvent>>>) {
671 let tracked = tracked_mock(
672 Some(TokenUsage {
673 prompt_tokens: 100,
674 completion_tokens: 20,
675 total_tokens: 120,
676 }),
677 tool_capable,
678 )
679 .with_metrics_sink(Arc::new(MockSink {
680 events: events.clone(),
681 fail: false,
682 }));
683 (tracked, events)
684 }
685
686 #[tokio::test]
687 async fn with_metrics_sink_exports_token_usage_after_chat() {
688 let events = Arc::new(Mutex::new(Vec::new()));
689 let (tracked, events) = tracked_mock_with_sink(false, events);
690
691 tracked
692 .chat(vec![Message::human("hi")], None)
693 .await
694 .unwrap();
695
696 let captured = events.lock().await;
697 assert_eq!(captured.len(), 1);
698 match &captured[0] {
699 ObsEvent::TokenUsage(u) => {
700 assert_eq!(u.prompt_tokens, 100);
701 assert_eq!(u.completion_tokens, 20);
702 assert_eq!(u.total_tokens, 120);
703 }
704 ObsEvent::AgentMetrics(_) => panic!("unexpected event kind"),
705 }
706 }
707
708 #[tokio::test]
709 async fn stream_chat_exports_usage_when_sink_attached() {
710 let events = Arc::new(Mutex::new(Vec::new()));
711 let llm = MockChatModel {
712 chat_usage: None,
713 stream_usage: Some(TokenUsage {
714 prompt_tokens: 50,
715 completion_tokens: 15,
716 total_tokens: 65,
717 }),
718 tool_capable: false,
719 };
720 let tracked = TokenTrackingLLM::new(llm, Arc::new(CharRatioCounter::new(4)))
721 .with_metrics_sink(Arc::new(MockSink {
722 events: events.clone(),
723 fail: false,
724 }));
725
726 let stream = tracked
727 .stream_chat(vec![Message::human("hi")], None)
728 .await
729 .unwrap();
730 let items: Vec<_> = stream.collect().await;
731 assert_eq!(items.len(), 2);
732 assert!(items[0].is_ok());
733
734 let captured = events.lock().await;
735 assert_eq!(captured.len(), 1);
736 match &captured[0] {
737 ObsEvent::TokenUsage(u) => assert_eq!(u.total_tokens, 65),
738 ObsEvent::AgentMetrics(_) => panic!("unexpected event kind"),
739 }
740 }
741
742 #[tokio::test]
743 async fn sink_export_failure_is_warned_and_flow_continues() {
744 let tracked = tracked_mock(
745 Some(TokenUsage {
746 prompt_tokens: 10,
747 completion_tokens: 4,
748 total_tokens: 14,
749 }),
750 false,
751 )
752 .with_metrics_sink(Arc::new(MockSink {
753 events: Arc::new(Mutex::new(Vec::new())),
754 fail: true,
755 }));
756
757 let result = tracked.chat(vec![Message::human("hi")], None).await;
758 assert!(result.is_ok(), "export failure must not propagate");
759
760 let usage = tracked.get_usage().await;
761 assert_eq!(usage.total_tokens, 14);
762 }
763
764 #[tokio::test]
765 async fn bind_tools_keeps_metrics_sink_attached() {
766 let events = Arc::new(Mutex::new(Vec::new()));
767 let (tracked, events) = tracked_mock_with_sink(true, events);
768
769 let bound = tracked
770 .bind_tools(vec![ToolDefinition::new(
771 "get_weather",
772 "Get current weather",
773 )])
774 .expect("tool-capable mock must bind");
775 bound.chat(vec![Message::human("hi")], None).await.unwrap();
776
777 let captured = events.lock().await;
778 assert_eq!(captured.len(), 1);
779 match &captured[0] {
780 ObsEvent::TokenUsage(u) => assert_eq!(u.prompt_tokens, 100),
781 ObsEvent::AgentMetrics(_) => panic!("unexpected event kind"),
782 }
783 }
784}