Skip to main content

lc_core/token_counter/
tracker.rs

1//! Token-tracking LLM wrapper and cost estimation
2
3use 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
19/// LLM wrapper with token statistics
20///
21/// Wraps any `BaseChatModel`, accumulating prompt / completion token usage automatically,
22/// preferring the real usage returned by the LLM, falling back to tiktoken estimates.
23///
24/// v0.20.1: implements `BaseChatModel` itself, so a tracked LLM can be plugged
25/// directly into framework agents (e.g. `FunctionCallingAgent::new`); every
26/// `chat`/`stream`/`bind_tools` call inside the agent loop is counted, and
27/// `get_usage`/`estimate_cost` reflect the cumulative agent-run usage.
28pub 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    /// Wraps an LLM with a custom counter.
36    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    /// Wraps with a Tiktoken (cl100k_base) counter
45    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    /// Calls the LLM and counts tokens.
51    ///
52    /// Inherent method (kept for backward compatibility); delegates to
53    /// [`Self::chat_tracked`]. When the model is reached through
54    /// `dyn BaseChatModel` (e.g. inside an agent), the trait `chat` takes over —
55    /// both count through the same helper, so the two paths never diverge.
56    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    /// Shared counting entry point for the inherent `chat` and the trait `chat`.
65    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        // prefer the real usage returned by the LLM, otherwise use the estimate.
74        // `TrackerTokenUsage` and `language_models::TokenUsage` are both usize,
75        // so no precision-loss conversion is needed (Q6).
76        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    /// Returns the cumulative usage
90    pub async fn get_usage(&self) -> TrackerTokenUsage {
91        self.usage.lock().await.clone()
92    }
93
94    /// Resets the statistics
95    pub async fn reset(&self) {
96        self.usage.lock().await.reset();
97    }
98
99    /// Estimates the cost (USD)
100    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        // `batch` keeps the default `Runnable` implementation (concurrent via
128        // `invoke`, which counts each input).
129        // Convert the counted `StreamChunk` stream into an `LLMResult` stream,
130        // mirroring `OpenAIChat::stream`.
131        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        // A rebuilt wrapper shares the same counter + usage Arcs, so the count
173        // survives parameter overrides.
174        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        // Forward the stream, counting only the real usage the provider reports
213        // (typically on the terminal chunk). Streaming cannot fall back to
214        // tiktoken estimates — there is no complete text to count — so the
215        // counted usage may be 0 for providers that never report it
216        // (v0.20.1 known boundary; estimation fallback moved to 0.21.0).
217        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        // Bind tools on the inner model, then re-wrap the bound model in a
238        // `TokenTrackingLLM` sharing the same counter + usage Arcs, so counting
239        // survives tool binding. Requires the `Box<dyn BaseChatModel>` glue
240        // (S3) so the box itself satisfies `L: BaseChatModel`.
241        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
250/// Model pricing (per 1K tokens, USD)
251pub struct ModelPricing {
252    /// Per-1K prompt token price (USD)
253    pub prompt_price_per_1k: f64,
254    /// Per-1K completion token price (USD)
255    pub completion_price_per_1k: f64,
256}
257
258impl ModelPricing {
259    /// Creates custom model pricing.
260    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    /// gpt-4o-mini pricing (USD / 1K tokens)
268    pub fn gpt4o_mini() -> Self {
269        Self::new(0.15, 0.60)
270    }
271
272    /// gpt-4o pricing (USD / 1K tokens)
273    pub fn gpt4o() -> Self {
274        Self::new(2.50, 10.00)
275    }
276
277    /// Calculates the cost
278    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        // 1000 prompt * 0.15/1k + 1000 completion * 0.60/1k = 0.75
292        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        // 500 * 1.0/1k + 250 * 2.0/1k = 0.5 + 0.5 = 1.0
306        let cost = p.calculate(500, 250);
307        assert!((cost - 1.0).abs() < 0.001);
308    }
309
310    // NOTE: Tests that require OpenAIChat live in the lc-providers crate
311    // because lc-core cannot depend on lc-providers (circular dependency).
312    // The TokenTrackingLLM integration is tested there instead.
313
314    use crate::language_models::TokenUsage;
315    use crate::token_counter::CharRatioCounter;
316
317    /// Mock model driving the `TokenTrackingLLM` trait tests (v0.20.1).
318    #[derive(Debug, Clone)]
319    struct MockChatModel {
320        /// Usage reported by `chat`; `None` = provider reports no usage.
321        chat_usage: Option<TokenUsage>,
322        /// Usage reported on the final streaming chunk; `None` = none in stream.
323        stream_usage: Option<TokenUsage>,
324        /// Whether `bind_tools` succeeds (tool-capable mock).
325        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        // The agent path reaches `TokenTrackingLLM` through `dyn BaseChatModel`,
457        // so the trait impl (not the inherent method) must count.
458        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        // bind_tools re-wraps the boxed model in a `TokenTrackingLLM` sharing the
544        // same usage `Arc`; counting must continue after tools are attached.
545        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        // "hello world" = 11 bytes, / 4 = 2
580        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}