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;
19use crate::cost::CostTracker;
20
21/// LLM wrapper with token statistics
22///
23/// Wraps any `BaseChatModel`, accumulating prompt / completion token usage automatically,
24/// preferring the real usage returned by the LLM, falling back to tiktoken estimates.
25///
26/// v0.20.1: implements `BaseChatModel` itself, so a tracked LLM can be plugged
27/// directly into framework agents (e.g. `FunctionCallingAgent::new`); every
28/// `chat`/`stream`/`bind_tools` call inside the agent loop is counted, and
29/// `get_usage`/`estimate_cost` reflect the cumulative agent-run usage.
30pub struct TokenTrackingLLM<L: BaseChatModel> {
31    llm: L,
32    counter: Arc<dyn TokenCounter>,
33    usage: Arc<Mutex<TrackerTokenUsage>>,
34    /// Optional observability sink (v0.20.2): exports a `TokenUsage` event after
35    /// each call that reports usage. `None` by default — behavior unchanged.
36    metrics_sink: Option<Arc<dyn MetricsSink>>,
37    /// Optional cost tracker (B3): prices and aggregates every counted call.
38    cost_tracker: Option<Arc<CostTracker>>,
39    /// Provider slug used when pricing calls (`"openai"`, ...); `None` prices by
40    /// model-only table entries.
41    provider: Option<String>,
42}
43
44impl<L: BaseChatModel> TokenTrackingLLM<L> {
45    /// Wraps an LLM with a custom counter.
46    pub fn new(llm: L, counter: Arc<dyn TokenCounter>) -> Self {
47        Self {
48            llm,
49            counter,
50            usage: Arc::new(Mutex::new(TrackerTokenUsage::new())),
51            metrics_sink: None,
52            cost_tracker: None,
53            provider: None,
54        }
55    }
56
57    /// Wraps with a Tiktoken (cl100k_base) counter
58    pub fn for_openai(llm: L) -> Result<Self, TokenCounterError> {
59        let counter = TiktokenCounter::new()?;
60        Ok(Self::new(llm, Arc::new(counter)))
61    }
62
63    /// Attaches an observability sink: after each call that reports usage the
64    /// wrapper exports a `TokenUsage` event (real when the provider reports it,
65    /// otherwise the tiktoken estimate). The sink is shared across wrappers
66    /// rebuilt by `bind_tools`/`with_temperature`/`with_max_tokens`.
67    pub fn with_metrics_sink(mut self, sink: Arc<dyn MetricsSink>) -> Self {
68        self.metrics_sink = Some(sink);
69        self
70    }
71
72    /// Attaches a [`CostTracker`] (B3): every counted call is priced under the
73    /// model's provider/id and aggregated on the shared tracker. Share one
74    /// `Arc<CostTracker>` across models/runs to get per-run or per-session
75    /// totals. The tracker keeps working when the model has no price entry
76    /// (tokens/calls counted, cost 0).
77    pub fn with_cost_tracker(mut self, tracker: Arc<CostTracker>) -> Self {
78        self.cost_tracker = Some(tracker);
79        self
80    }
81
82    /// Declares the provider slug (`"openai"`, `"anthropic"`, ...) used when
83    /// looking up prices. Defaults to `None` (model-only table lookup).
84    pub fn with_provider(mut self, provider: impl Into<String>) -> Self {
85        self.provider = Some(provider.into());
86        self
87    }
88
89    /// Calls the LLM and counts tokens.
90    ///
91    /// Inherent method (kept for backward compatibility); delegates to
92    /// `Self::chat_tracked`. When the model is reached through
93    /// `dyn BaseChatModel` (e.g. inside an agent), the trait `chat` takes over —
94    /// both count through the same helper, so the two paths never diverge.
95    pub async fn chat(
96        &self,
97        messages: Vec<Message>,
98        config: Option<RunnableConfig>,
99    ) -> Result<LLMResult, L::Error> {
100        self.chat_tracked(messages, config).await
101    }
102
103    /// Shared counting entry point for the inherent `chat` and the trait `chat`.
104    async fn chat_tracked(
105        &self,
106        messages: Vec<Message>,
107        config: Option<RunnableConfig>,
108    ) -> Result<LLMResult, L::Error> {
109        let estimated_prompt = self.counter.count_messages(&messages);
110        let result = self.llm.chat(messages, config).await?;
111
112        // prefer the real usage returned by the LLM, otherwise use the estimate.
113        // `TrackerTokenUsage` and `language_models::TokenUsage` are both usize,
114        // so no precision-loss conversion is needed (Q6).
115        let (prompt, completion) = result
116            .token_usage
117            .as_ref()
118            .map(|u| (u.prompt_tokens, u.completion_tokens))
119            .unwrap_or((
120                estimated_prompt as usize,
121                self.counter.count_tokens(&result.content) as usize,
122            ));
123
124        self.usage.lock().await.add(prompt, completion);
125
126        // B3: price the call on the shared tracker (no-op without a tracker;
127        // unpriced models aggregate tokens/calls at zero cost).
128        if let Some(tracker) = &self.cost_tracker {
129            let model = if result.model.is_empty() {
130                self.llm.model_name().to_string()
131            } else {
132                result.model.clone()
133            };
134            tracker
135                .record(self.provider.as_deref(), &model, prompt, completion)
136                .await;
137        }
138
139        // v0.20.2: export a TokenUsage event once counted (real or estimate).
140        // Failure is only warned — never propagated to the caller.
141        if let Some(sink) = &self.metrics_sink {
142            let evt = ObsEvent::TokenUsage(TokenUsage {
143                prompt_tokens: prompt,
144                completion_tokens: completion,
145                total_tokens: result
146                    .token_usage
147                    .as_ref()
148                    .map(|u| u.total_tokens)
149                    .unwrap_or(prompt + completion),
150            });
151            if let Err(e) = sink.export(&evt).await {
152                log::warn!(target: "lc_core::token_counter", "token usage export failed: {e}");
153            }
154        }
155
156        Ok(result)
157    }
158
159    /// Returns the cumulative usage
160    pub async fn get_usage(&self) -> TrackerTokenUsage {
161        self.usage.lock().await.clone()
162    }
163
164    /// Resets the statistics
165    pub async fn reset(&self) {
166        self.usage.lock().await.reset();
167    }
168
169    /// Estimates the cost (USD)
170    pub async fn estimate_cost(&self, pricing: &ModelPricing) -> f64 {
171        let usage = self.get_usage().await;
172        pricing.calculate(usage.prompt_tokens, usage.completion_tokens)
173    }
174}
175
176#[async_trait]
177impl<L> Runnable<Vec<Message>, LLMResult> for TokenTrackingLLM<L>
178where
179    L: BaseChatModel + Send + Sync,
180{
181    type Error = L::Error;
182
183    async fn invoke(
184        &self,
185        input: Vec<Message>,
186        config: Option<RunnableConfig>,
187    ) -> Result<LLMResult, Self::Error> {
188        self.chat_tracked(input, config).await
189    }
190
191    async fn stream(
192        &self,
193        input: Vec<Message>,
194        config: Option<RunnableConfig>,
195    ) -> Result<Pin<Box<dyn Stream<Item = Result<LLMResult, Self::Error>> + Send>>, Self::Error>
196    {
197        // `batch` keeps the default `Runnable` implementation (concurrent via
198        // `invoke`, which counts each input).
199        // Convert the counted `StreamChunk` stream into an `LLMResult` stream,
200        // mirroring `OpenAIChat::stream`.
201        let model = self.llm.model_name().to_string();
202        let stream = self.stream_chat(input, config).await?;
203        let stream = stream.map(move |item| match item {
204            Ok(chunk) => Ok(LLMResult {
205                content: chunk.text,
206                model: model.clone(),
207                token_usage: chunk.token_usage,
208                tool_calls: chunk.tool_calls,
209                thinking_content: None,
210            }),
211            Err(e) => Err(e),
212        });
213        Ok(Box::pin(stream))
214    }
215}
216
217#[async_trait]
218impl<L> BaseLanguageModel<Vec<Message>, LLMResult> for TokenTrackingLLM<L>
219where
220    L: BaseChatModel + Send + Sync,
221{
222    fn model_name(&self) -> &str {
223        self.llm.model_name()
224    }
225
226    fn get_num_tokens(&self, text: &str) -> usize {
227        self.llm.get_num_tokens(text)
228    }
229
230    fn temperature(&self) -> Option<f32> {
231        self.llm.temperature()
232    }
233
234    fn max_tokens(&self) -> Option<usize> {
235        self.llm.max_tokens()
236    }
237
238    fn with_temperature(self, temp: f32) -> Self
239    where
240        Self: Sized,
241    {
242        // A rebuilt wrapper shares the same counter + usage Arcs, so the count
243        // (and cost aggregation) survives parameter overrides.
244        Self {
245            llm: self.llm.with_temperature(temp),
246            counter: self.counter.clone(),
247            usage: self.usage.clone(),
248            metrics_sink: self.metrics_sink.clone(),
249            cost_tracker: self.cost_tracker.clone(),
250            provider: self.provider.clone(),
251        }
252    }
253
254    fn with_max_tokens(self, max: usize) -> Self
255    where
256        Self: Sized,
257    {
258        Self {
259            llm: self.llm.with_max_tokens(max),
260            counter: self.counter.clone(),
261            usage: self.usage.clone(),
262            metrics_sink: self.metrics_sink.clone(),
263            cost_tracker: self.cost_tracker.clone(),
264            provider: self.provider.clone(),
265        }
266    }
267}
268
269#[async_trait]
270impl<L> BaseChatModel for TokenTrackingLLM<L>
271where
272    L: BaseChatModel + Send + Sync,
273{
274    async fn chat(
275        &self,
276        messages: Vec<Message>,
277        config: Option<RunnableConfig>,
278    ) -> Result<LLMResult, Self::Error> {
279        self.chat_tracked(messages, config).await
280    }
281
282    async fn stream_chat(
283        &self,
284        messages: Vec<Message>,
285        config: Option<RunnableConfig>,
286    ) -> Result<Pin<Box<dyn Stream<Item = Result<StreamChunk, Self::Error>> + Send>>, Self::Error>
287    {
288        // Forward the stream, counting only the real usage the provider reports
289        // (typically on the terminal chunk). Streaming cannot fall back to
290        // tiktoken estimates — there is no complete text to count — so the
291        // counted usage may be 0 for providers that never report it
292        // (v0.20.1 known boundary; estimation fallback moved to 0.21.0).
293        let model_name = self.llm.model_name().to_string();
294        let stream = self.llm.stream_chat(messages, config).await?;
295        let usage = self.usage.clone();
296        let sink = self.metrics_sink.clone();
297        let cost_tracker = self.cost_tracker.clone();
298        let provider = self.provider.clone();
299        let stream = stream.then(move |item| {
300            let usage = usage.clone();
301            let sink = sink.clone();
302            let cost_tracker = cost_tracker.clone();
303            let provider = provider.clone();
304            let model_name = model_name.clone();
305            async move {
306                if let Ok(chunk) = &item {
307                    if let Some(u) = &chunk.token_usage {
308                        usage.lock().await.add(u.prompt_tokens, u.completion_tokens);
309                        if let Some(tracker) = &cost_tracker {
310                            tracker
311                                .record(
312                                    provider.as_deref(),
313                                    &model_name,
314                                    u.prompt_tokens,
315                                    u.completion_tokens,
316                                )
317                                .await;
318                        }
319                        if let Some(sink) = &sink {
320                            let evt = ObsEvent::TokenUsage(u.clone());
321                            if let Err(e) = sink.export(&evt).await {
322                                log::warn!(target: "lc_core::token_counter", "token usage export failed: {e}");
323                            }
324                        }
325                    }
326                }
327                item
328            }
329        });
330        Ok(Box::pin(stream))
331    }
332
333    fn bind_tools(
334        &self,
335        tools: Vec<ToolDefinition>,
336    ) -> Option<Box<dyn BaseChatModel<Error = Self::Error> + Send + Sync>> {
337        // Bind tools on the inner model, then re-wrap the bound model in a
338        // `TokenTrackingLLM` sharing the same counter + usage Arcs, so counting
339        // survives tool binding. Requires the `Box<dyn BaseChatModel>` glue
340        // (S3) so the box itself satisfies `L: BaseChatModel`.
341        let bound = self.llm.bind_tools(tools)?;
342        Some(Box::new(TokenTrackingLLM {
343            llm: bound,
344            counter: self.counter.clone(),
345            usage: self.usage.clone(),
346            metrics_sink: self.metrics_sink.clone(),
347            cost_tracker: self.cost_tracker.clone(),
348            provider: self.provider.clone(),
349        }))
350    }
351}
352
353/// Model pricing (per 1K tokens, USD)
354pub struct ModelPricing {
355    /// Per-1K prompt token price (USD)
356    pub prompt_price_per_1k: f64,
357    /// Per-1K completion token price (USD)
358    pub completion_price_per_1k: f64,
359}
360
361impl ModelPricing {
362    /// Creates custom model pricing.
363    pub fn new(prompt: f64, completion: f64) -> Self {
364        Self {
365            prompt_price_per_1k: prompt,
366            completion_price_per_1k: completion,
367        }
368    }
369
370    /// gpt-4o-mini pricing (USD / 1K tokens)
371    pub fn gpt4o_mini() -> Self {
372        Self::new(0.15, 0.60)
373    }
374
375    /// gpt-4o pricing (USD / 1K tokens)
376    pub fn gpt4o() -> Self {
377        Self::new(2.50, 10.00)
378    }
379
380    /// Calculates the cost
381    pub fn calculate(&self, prompt: usize, completion: usize) -> f64 {
382        (prompt as f64 / 1000.0) * self.prompt_price_per_1k
383            + (completion as f64 / 1000.0) * self.completion_price_per_1k
384    }
385}
386
387#[cfg(test)]
388mod tests {
389    use super::*;
390
391    #[test]
392    fn test_model_pricing_gpt4o_mini() {
393        let p = ModelPricing::gpt4o_mini();
394        // 1000 prompt * 0.15/1k + 1000 completion * 0.60/1k = 0.75
395        let cost = p.calculate(1000, 1000);
396        assert!((cost - 0.75).abs() < 0.001);
397    }
398
399    #[test]
400    fn test_model_pricing_zero() {
401        let p = ModelPricing::gpt4o_mini();
402        assert_eq!(p.calculate(0, 0), 0.0);
403    }
404
405    #[test]
406    fn test_model_pricing_custom() {
407        let p = ModelPricing::new(1.0, 2.0);
408        // 500 * 1.0/1k + 250 * 2.0/1k = 0.5 + 0.5 = 1.0
409        let cost = p.calculate(500, 250);
410        assert!((cost - 1.0).abs() < 0.001);
411    }
412
413    // NOTE: Tests that require OpenAIChat live in the lc-providers crate
414    // because lc-core cannot depend on lc-providers (circular dependency).
415    // The TokenTrackingLLM integration is tested there instead.
416
417    use crate::language_models::TokenUsage;
418    use crate::observability::ObsError;
419    use crate::token_counter::CharRatioCounter;
420
421    /// Mock model driving the `TokenTrackingLLM` trait tests (v0.20.1).
422    #[derive(Debug, Clone)]
423    struct MockChatModel {
424        /// Usage reported by `chat`; `None` = provider reports no usage.
425        chat_usage: Option<TokenUsage>,
426        /// Usage reported on the final streaming chunk; `None` = none in stream.
427        stream_usage: Option<TokenUsage>,
428        /// Whether `bind_tools` succeeds (tool-capable mock).
429        tool_capable: bool,
430    }
431
432    #[derive(Debug)]
433    struct MockError;
434
435    impl std::fmt::Display for MockError {
436        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
437            write!(f, "mock error")
438        }
439    }
440
441    impl std::error::Error for MockError {}
442
443    #[async_trait]
444    impl Runnable<Vec<Message>, LLMResult> for MockChatModel {
445        type Error = MockError;
446
447        async fn invoke(
448            &self,
449            input: Vec<Message>,
450            config: Option<RunnableConfig>,
451        ) -> Result<LLMResult, Self::Error> {
452            self.chat(input, config).await
453        }
454    }
455
456    #[async_trait]
457    impl BaseLanguageModel<Vec<Message>, LLMResult> for MockChatModel {
458        fn model_name(&self) -> &str {
459            "mock-model"
460        }
461
462        fn get_num_tokens(&self, text: &str) -> usize {
463            text.len() / 4
464        }
465
466        fn with_temperature(self, _temp: f32) -> Self
467        where
468            Self: Sized,
469        {
470            self
471        }
472
473        fn with_max_tokens(self, _max: usize) -> Self
474        where
475            Self: Sized,
476        {
477            self
478        }
479    }
480
481    #[async_trait]
482    impl BaseChatModel for MockChatModel {
483        async fn chat(
484            &self,
485            _messages: Vec<Message>,
486            _config: Option<RunnableConfig>,
487        ) -> Result<LLMResult, Self::Error> {
488            Ok(LLMResult {
489                content: "mock reply".to_string(),
490                model: "mock-model".to_string(),
491                token_usage: self.chat_usage.clone(),
492                tool_calls: None,
493                thinking_content: None,
494            })
495        }
496
497        async fn stream_chat(
498            &self,
499            _messages: Vec<Message>,
500            _config: Option<RunnableConfig>,
501        ) -> Result<Pin<Box<dyn Stream<Item = Result<StreamChunk, Self::Error>> + Send>>, Self::Error>
502        {
503            let chunks = vec![
504                Ok(StreamChunk::new("hello")),
505                Ok(StreamChunk {
506                    text: " world".to_string(),
507                    token_usage: self.stream_usage.clone(),
508                    tool_calls: None,
509                }),
510            ];
511            Ok(Box::pin(futures_util::stream::iter(chunks)))
512        }
513
514        fn bind_tools(
515            &self,
516            _tools: Vec<ToolDefinition>,
517        ) -> Option<Box<dyn BaseChatModel<Error = Self::Error> + Send + Sync>> {
518            self.tool_capable.then(|| {
519                Box::new(self.clone()) as Box<dyn BaseChatModel<Error = MockError> + Send + Sync>
520            })
521        }
522    }
523
524    fn tracked_mock(
525        chat_usage: Option<TokenUsage>,
526        tool_capable: bool,
527    ) -> TokenTrackingLLM<MockChatModel> {
528        TokenTrackingLLM::new(
529            MockChatModel {
530                chat_usage,
531                stream_usage: None,
532                tool_capable,
533            },
534            Arc::new(CharRatioCounter::new(4)),
535        )
536    }
537
538    #[tokio::test]
539    async fn chat_accumulates_real_usage_across_calls() {
540        let tracked = tracked_mock(
541            Some(TokenUsage {
542                prompt_tokens: 100,
543                completion_tokens: 20,
544                total_tokens: 120,
545            }),
546            false,
547        );
548        let msgs = vec![Message::human("hi")];
549        tracked.chat(msgs.clone(), None).await.unwrap();
550        tracked.chat(msgs, None).await.unwrap();
551
552        let usage = tracked.get_usage().await;
553        assert_eq!(usage.prompt_tokens, 200);
554        assert_eq!(usage.completion_tokens, 40);
555        assert_eq!(usage.total_tokens, 240);
556    }
557
558    #[tokio::test]
559    async fn chat_via_dyn_base_chat_model_counts() {
560        // The agent path reaches `TokenTrackingLLM` through `dyn BaseChatModel`,
561        // so the trait impl (not the inherent method) must count.
562        let tracked = tracked_mock(
563            Some(TokenUsage {
564                prompt_tokens: 7,
565                completion_tokens: 3,
566                total_tokens: 10,
567            }),
568            false,
569        );
570        let model: &dyn BaseChatModel<Error = MockError> = &tracked;
571        model.chat(vec![Message::human("hi")], None).await.unwrap();
572
573        let usage = tracked.get_usage().await;
574        assert_eq!(usage.prompt_tokens, 7);
575        assert_eq!(usage.completion_tokens, 3);
576    }
577
578    #[tokio::test]
579    async fn chat_estimates_when_provider_reports_no_usage() {
580        let tracked = tracked_mock(None, false);
581        tracked
582            .chat(vec![Message::human("hello world")], None)
583            .await
584            .unwrap();
585
586        let usage = tracked.get_usage().await;
587        assert!(usage.prompt_tokens > 0, "prompt should be estimated");
588        assert!(
589            usage.completion_tokens > 0,
590            "completion should be estimated"
591        );
592    }
593
594    #[tokio::test]
595    async fn stream_chat_accumulates_real_usage() {
596        let llm = MockChatModel {
597            chat_usage: None,
598            stream_usage: Some(TokenUsage {
599                prompt_tokens: 50,
600                completion_tokens: 15,
601                total_tokens: 65,
602            }),
603            tool_capable: false,
604        };
605        let tracked = TokenTrackingLLM::new(llm, Arc::new(CharRatioCounter::new(4)));
606
607        let stream = tracked
608            .stream_chat(vec![Message::human("hi")], None)
609            .await
610            .unwrap();
611        let items: Vec<_> = stream.collect().await;
612        assert_eq!(items.len(), 2);
613        assert!(items[0].is_ok());
614
615        let usage = tracked.get_usage().await;
616        assert_eq!(usage.prompt_tokens, 50);
617        assert_eq!(usage.completion_tokens, 15);
618    }
619
620    #[tokio::test]
621    async fn runnable_stream_counts_through_stream_chat() {
622        let llm = MockChatModel {
623            chat_usage: None,
624            stream_usage: Some(TokenUsage {
625                prompt_tokens: 5,
626                completion_tokens: 5,
627                total_tokens: 10,
628            }),
629            tool_capable: false,
630        };
631        let tracked = TokenTrackingLLM::new(llm, Arc::new(CharRatioCounter::new(4)));
632
633        let stream = tracked
634            .stream(vec![Message::human("hi")], None)
635            .await
636            .unwrap();
637        let items: Vec<_> = stream.collect().await;
638        assert_eq!(items.len(), 2);
639        assert!(items[0].is_ok());
640
641        let usage = tracked.get_usage().await;
642        assert_eq!(usage.total_tokens, 10);
643    }
644
645    #[tokio::test]
646    async fn bind_tools_keeps_shared_usage() {
647        // bind_tools re-wraps the boxed model in a `TokenTrackingLLM` sharing the
648        // same usage `Arc`; counting must continue after tools are attached.
649        let tracked = tracked_mock(
650            Some(TokenUsage {
651                prompt_tokens: 10,
652                completion_tokens: 4,
653                total_tokens: 14,
654            }),
655            true,
656        );
657
658        let bound = tracked
659            .bind_tools(vec![ToolDefinition::new(
660                "get_weather",
661                "Get current weather",
662            )])
663            .expect("tool-capable mock must bind");
664        bound.chat(vec![Message::human("hi")], None).await.unwrap();
665
666        let usage = tracked.get_usage().await;
667        assert_eq!(usage.prompt_tokens, 10);
668        assert_eq!(usage.completion_tokens, 4);
669    }
670
671    #[test]
672    fn bind_tools_returns_none_when_model_incapable() {
673        let tracked = tracked_mock(None, false);
674        assert!(tracked
675            .bind_tools(vec![ToolDefinition::new("t", "t")])
676            .is_none());
677    }
678
679    #[test]
680    fn base_model_metadata_passthrough() {
681        let tracked = tracked_mock(None, false);
682        assert_eq!(tracked.model_name(), "mock-model");
683        // "hello world" = 11 bytes, / 4 = 2
684        assert_eq!(tracked.get_num_tokens("hello world"), 2);
685    }
686
687    #[tokio::test]
688    async fn with_temperature_preserves_usage() {
689        let tracked = tracked_mock(
690            Some(TokenUsage {
691                prompt_tokens: 3,
692                completion_tokens: 1,
693                total_tokens: 4,
694            }),
695            false,
696        );
697        let tracked = tracked.with_temperature(0.5).with_max_tokens(128);
698        tracked
699            .chat(vec![Message::human("hi")], None)
700            .await
701            .unwrap();
702
703        let usage = tracked.get_usage().await;
704        assert_eq!(usage.prompt_tokens, 3);
705        assert_eq!(usage.completion_tokens, 1);
706    }
707
708    // --- v0.20.2: observability sink ---------------------------------------
709
710    /// Mock sink recording events in a shared buffer.
711    struct MockSink {
712        events: Arc<Mutex<Vec<ObsEvent>>>,
713        fail: bool,
714    }
715
716    #[async_trait]
717    impl MetricsSink for MockSink {
718        async fn export(&self, event: &ObsEvent) -> Result<(), ObsError> {
719            if self.fail {
720                return Err(ObsError::Transport("mock failure".to_string()));
721            }
722            self.events.lock().await.push(event.clone());
723            Ok(())
724        }
725    }
726
727    fn tracked_mock_with_sink(
728        tool_capable: bool,
729        events: Arc<Mutex<Vec<ObsEvent>>>,
730    ) -> (TokenTrackingLLM<MockChatModel>, Arc<Mutex<Vec<ObsEvent>>>) {
731        let tracked = tracked_mock(
732            Some(TokenUsage {
733                prompt_tokens: 100,
734                completion_tokens: 20,
735                total_tokens: 120,
736            }),
737            tool_capable,
738        )
739        .with_metrics_sink(Arc::new(MockSink {
740            events: events.clone(),
741            fail: false,
742        }));
743        (tracked, events)
744    }
745
746    #[tokio::test]
747    async fn with_metrics_sink_exports_token_usage_after_chat() {
748        let events = Arc::new(Mutex::new(Vec::new()));
749        let (tracked, events) = tracked_mock_with_sink(false, events);
750
751        tracked
752            .chat(vec![Message::human("hi")], None)
753            .await
754            .unwrap();
755
756        let captured = events.lock().await;
757        assert_eq!(captured.len(), 1);
758        match &captured[0] {
759            ObsEvent::TokenUsage(u) => {
760                assert_eq!(u.prompt_tokens, 100);
761                assert_eq!(u.completion_tokens, 20);
762                assert_eq!(u.total_tokens, 120);
763            }
764            ObsEvent::AgentMetrics(_) => panic!("unexpected event kind"),
765            ObsEvent::Cost(_) => panic!("unexpected event kind"),
766        }
767    }
768
769    #[tokio::test]
770    async fn stream_chat_exports_usage_when_sink_attached() {
771        let events = Arc::new(Mutex::new(Vec::new()));
772        let llm = MockChatModel {
773            chat_usage: None,
774            stream_usage: Some(TokenUsage {
775                prompt_tokens: 50,
776                completion_tokens: 15,
777                total_tokens: 65,
778            }),
779            tool_capable: false,
780        };
781        let tracked = TokenTrackingLLM::new(llm, Arc::new(CharRatioCounter::new(4)))
782            .with_metrics_sink(Arc::new(MockSink {
783                events: events.clone(),
784                fail: false,
785            }));
786
787        let stream = tracked
788            .stream_chat(vec![Message::human("hi")], None)
789            .await
790            .unwrap();
791        let items: Vec<_> = stream.collect().await;
792        assert_eq!(items.len(), 2);
793        assert!(items[0].is_ok());
794
795        let captured = events.lock().await;
796        assert_eq!(captured.len(), 1);
797        match &captured[0] {
798            ObsEvent::TokenUsage(u) => assert_eq!(u.total_tokens, 65),
799            ObsEvent::AgentMetrics(_) => panic!("unexpected event kind"),
800            ObsEvent::Cost(_) => panic!("unexpected event kind"),
801        }
802    }
803
804    #[tokio::test]
805    async fn sink_export_failure_is_warned_and_flow_continues() {
806        let tracked = tracked_mock(
807            Some(TokenUsage {
808                prompt_tokens: 10,
809                completion_tokens: 4,
810                total_tokens: 14,
811            }),
812            false,
813        )
814        .with_metrics_sink(Arc::new(MockSink {
815            events: Arc::new(Mutex::new(Vec::new())),
816            fail: true,
817        }));
818
819        let result = tracked.chat(vec![Message::human("hi")], None).await;
820        assert!(result.is_ok(), "export failure must not propagate");
821
822        let usage = tracked.get_usage().await;
823        assert_eq!(usage.total_tokens, 14);
824    }
825
826    #[tokio::test]
827    async fn bind_tools_keeps_metrics_sink_attached() {
828        let events = Arc::new(Mutex::new(Vec::new()));
829        let (tracked, events) = tracked_mock_with_sink(true, events);
830
831        let bound = tracked
832            .bind_tools(vec![ToolDefinition::new(
833                "get_weather",
834                "Get current weather",
835            )])
836            .expect("tool-capable mock must bind");
837        bound.chat(vec![Message::human("hi")], None).await.unwrap();
838
839        let captured = events.lock().await;
840        assert_eq!(captured.len(), 1);
841        match &captured[0] {
842            ObsEvent::TokenUsage(u) => assert_eq!(u.prompt_tokens, 100),
843            ObsEvent::AgentMetrics(_) => panic!("unexpected event kind"),
844            ObsEvent::Cost(_) => panic!("unexpected event kind"),
845        }
846    }
847
848    // --- B3: CostTracker integration ---------------------------------------
849
850    use crate::cost::{CostTracker, ModelPrice, PricingTable};
851
852    #[tokio::test]
853    async fn chat_prices_calls_on_shared_cost_tracker() {
854        let table = PricingTable::new().with("mock", "mock-model", ModelPrice::new(2.0, 8.0));
855        let tracker = Arc::new(CostTracker::new(Arc::new(table)));
856        let tracked = tracked_mock(
857            Some(TokenUsage {
858                prompt_tokens: 1000,
859                completion_tokens: 500,
860                total_tokens: 1500,
861            }),
862            false,
863        )
864        .with_provider("mock")
865        .with_cost_tracker(tracker.clone());
866
867        tracked
868            .chat(vec![Message::human("hi")], None)
869            .await
870            .unwrap();
871        tracked
872            .chat(vec![Message::human("hi")], None)
873            .await
874            .unwrap();
875
876        // 2 * (1000*2/1k + 500*8/1k) = 2 * 6 = 12
877        assert_eq!(tracker.total_cost_usd().await, 12.0);
878        let report = tracker.report().await;
879        assert_eq!(report.calls, 2);
880        assert_eq!(report.by_model["mock/mock-model"].cost_usd, 12.0);
881    }
882
883    #[tokio::test]
884    async fn stream_prices_terminal_usage_on_cost_tracker() {
885        let table = PricingTable::new().with("mock", "mock-model", ModelPrice::new(1.0, 2.0));
886        let tracker = Arc::new(CostTracker::new(Arc::new(table)));
887        let llm = MockChatModel {
888            chat_usage: None,
889            stream_usage: Some(TokenUsage {
890                prompt_tokens: 1000,
891                completion_tokens: 1000,
892                total_tokens: 2000,
893            }),
894            tool_capable: false,
895        };
896        let tracked = TokenTrackingLLM::new(llm, Arc::new(CharRatioCounter::new(4)))
897            .with_provider("mock")
898            .with_cost_tracker(tracker.clone());
899
900        let stream = tracked
901            .stream_chat(vec![Message::human("hi")], None)
902            .await
903            .unwrap();
904        let _: Vec<_> = stream.collect().await;
905
906        // 1000*1/1k + 1000*2/1k = 3
907        assert_eq!(tracker.total_cost_usd().await, 3.0);
908    }
909
910    #[tokio::test]
911    async fn cost_tracker_survives_temperature_and_tool_rebuilds() {
912        // "mock-model" is not in the built-in table: calls count but price zero.
913        let tracker = Arc::new(CostTracker::with_builtin_prices());
914        let tracked = tracked_mock(
915            Some(TokenUsage {
916                prompt_tokens: 10,
917                completion_tokens: 10,
918                total_tokens: 20,
919            }),
920            true,
921        )
922        .with_cost_tracker(tracker.clone());
923
924        let rebuilt = tracked.with_temperature(0.1).with_max_tokens(64);
925        rebuilt
926            .chat(vec![Message::human("hi")], None)
927            .await
928            .unwrap();
929        let bound = rebuilt
930            .bind_tools(vec![ToolDefinition::new("t", "t")])
931            .unwrap();
932        bound.chat(vec![Message::human("hi")], None).await.unwrap();
933
934        // Unpriced "mock-model" => cost 0, but both rebuilt wrappers count.
935        assert_eq!(tracker.report().await.calls, 2);
936        assert_eq!(tracker.total_cost_usd().await, 0.0);
937    }
938}