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, 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
20/// LLM wrapper with token statistics
21///
22/// Wraps any `BaseChatModel`, accumulating prompt / completion token usage automatically,
23/// preferring the real usage returned by the LLM, falling back to tiktoken estimates.
24///
25/// v0.20.1: implements `BaseChatModel` itself, so a tracked LLM can be plugged
26/// directly into framework agents (e.g. `FunctionCallingAgent::new`); every
27/// `chat`/`stream`/`bind_tools` call inside the agent loop is counted, and
28/// `get_usage`/`estimate_cost` reflect the cumulative agent-run usage.
29pub struct TokenTrackingLLM<L: BaseChatModel> {
30    llm: L,
31    counter: Arc<dyn TokenCounter>,
32    usage: Arc<Mutex<TrackerTokenUsage>>,
33    /// Optional observability sink (v0.20.2): exports a `TokenUsage` event after
34    /// each call that reports usage. `None` by default — behavior unchanged.
35    metrics_sink: Option<Arc<dyn MetricsSink>>,
36}
37
38impl<L: BaseChatModel> TokenTrackingLLM<L> {
39    /// Wraps an LLM with a custom counter.
40    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    /// Wraps with a Tiktoken (cl100k_base) counter
50    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    /// Attaches an observability sink: after each call that reports usage the
56    /// wrapper exports a `TokenUsage` event (real when the provider reports it,
57    /// otherwise the tiktoken estimate). The sink is shared across wrappers
58    /// rebuilt by `bind_tools`/`with_temperature`/`with_max_tokens`.
59    pub fn with_metrics_sink(mut self, sink: Arc<dyn MetricsSink>) -> Self {
60        self.metrics_sink = Some(sink);
61        self
62    }
63
64    /// Calls the LLM and counts tokens.
65    ///
66    /// Inherent method (kept for backward compatibility); delegates to
67    /// `Self::chat_tracked`. When the model is reached through
68    /// `dyn BaseChatModel` (e.g. inside an agent), the trait `chat` takes over —
69    /// both count through the same helper, so the two paths never diverge.
70    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    /// Shared counting entry point for the inherent `chat` and the trait `chat`.
79    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        // prefer the real usage returned by the LLM, otherwise use the estimate.
88        // `TrackerTokenUsage` and `language_models::TokenUsage` are both usize,
89        // so no precision-loss conversion is needed (Q6).
90        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        // v0.20.2: export a TokenUsage event once counted (real or estimate).
102        // Failure is only warned — never propagated to the caller.
103        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    /// Returns the cumulative usage
122    pub async fn get_usage(&self) -> TrackerTokenUsage {
123        self.usage.lock().await.clone()
124    }
125
126    /// Resets the statistics
127    pub async fn reset(&self) {
128        self.usage.lock().await.reset();
129    }
130
131    /// Estimates the cost (USD)
132    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        // `batch` keeps the default `Runnable` implementation (concurrent via
160        // `invoke`, which counts each input).
161        // Convert the counted `StreamChunk` stream into an `LLMResult` stream,
162        // mirroring `OpenAIChat::stream`.
163        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        // A rebuilt wrapper shares the same counter + usage Arcs, so the count
205        // survives parameter overrides.
206        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        // Forward the stream, counting only the real usage the provider reports
247        // (typically on the terminal chunk). Streaming cannot fall back to
248        // tiktoken estimates — there is no complete text to count — so the
249        // counted usage may be 0 for providers that never report it
250        // (v0.20.1 known boundary; estimation fallback moved to 0.21.0).
251        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        // Bind tools on the inner model, then re-wrap the bound model in a
280        // `TokenTrackingLLM` sharing the same counter + usage Arcs, so counting
281        // survives tool binding. Requires the `Box<dyn BaseChatModel>` glue
282        // (S3) so the box itself satisfies `L: BaseChatModel`.
283        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
293/// Model pricing (per 1K tokens, USD)
294pub struct ModelPricing {
295    /// Per-1K prompt token price (USD)
296    pub prompt_price_per_1k: f64,
297    /// Per-1K completion token price (USD)
298    pub completion_price_per_1k: f64,
299}
300
301impl ModelPricing {
302    /// Creates custom model pricing.
303    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    /// gpt-4o-mini pricing (USD / 1K tokens)
311    pub fn gpt4o_mini() -> Self {
312        Self::new(0.15, 0.60)
313    }
314
315    /// gpt-4o pricing (USD / 1K tokens)
316    pub fn gpt4o() -> Self {
317        Self::new(2.50, 10.00)
318    }
319
320    /// Calculates the cost
321    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        // 1000 prompt * 0.15/1k + 1000 completion * 0.60/1k = 0.75
335        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        // 500 * 1.0/1k + 250 * 2.0/1k = 0.5 + 0.5 = 1.0
349        let cost = p.calculate(500, 250);
350        assert!((cost - 1.0).abs() < 0.001);
351    }
352
353    // NOTE: Tests that require OpenAIChat live in the lc-providers crate
354    // because lc-core cannot depend on lc-providers (circular dependency).
355    // The TokenTrackingLLM integration is tested there instead.
356
357    use crate::language_models::TokenUsage;
358    use crate::observability::ObsError;
359    use crate::token_counter::CharRatioCounter;
360
361    /// Mock model driving the `TokenTrackingLLM` trait tests (v0.20.1).
362    #[derive(Debug, Clone)]
363    struct MockChatModel {
364        /// Usage reported by `chat`; `None` = provider reports no usage.
365        chat_usage: Option<TokenUsage>,
366        /// Usage reported on the final streaming chunk; `None` = none in stream.
367        stream_usage: Option<TokenUsage>,
368        /// Whether `bind_tools` succeeds (tool-capable mock).
369        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        // The agent path reaches `TokenTrackingLLM` through `dyn BaseChatModel`,
501        // so the trait impl (not the inherent method) must count.
502        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        // bind_tools re-wraps the boxed model in a `TokenTrackingLLM` sharing the
588        // same usage `Arc`; counting must continue after tools are attached.
589        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        // "hello world" = 11 bytes, / 4 = 2
624        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    // --- v0.20.2: observability sink ---------------------------------------
649
650    /// Mock sink recording events in a shared buffer.
651    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}