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                    thinking_content: None,
507                    text: " world".to_string(),
508                    token_usage: self.stream_usage.clone(),
509                    tool_calls: None,
510                }),
511            ];
512            Ok(Box::pin(futures_util::stream::iter(chunks)))
513        }
514
515        fn bind_tools(
516            &self,
517            _tools: Vec<ToolDefinition>,
518        ) -> Option<Box<dyn BaseChatModel<Error = Self::Error> + Send + Sync>> {
519            self.tool_capable.then(|| {
520                Box::new(self.clone()) as Box<dyn BaseChatModel<Error = MockError> + Send + Sync>
521            })
522        }
523    }
524
525    fn tracked_mock(
526        chat_usage: Option<TokenUsage>,
527        tool_capable: bool,
528    ) -> TokenTrackingLLM<MockChatModel> {
529        TokenTrackingLLM::new(
530            MockChatModel {
531                chat_usage,
532                stream_usage: None,
533                tool_capable,
534            },
535            Arc::new(CharRatioCounter::new(4)),
536        )
537    }
538
539    #[tokio::test]
540    async fn chat_accumulates_real_usage_across_calls() {
541        let tracked = tracked_mock(
542            Some(TokenUsage {
543                prompt_tokens: 100,
544                completion_tokens: 20,
545                total_tokens: 120,
546            }),
547            false,
548        );
549        let msgs = vec![Message::human("hi")];
550        tracked.chat(msgs.clone(), None).await.unwrap();
551        tracked.chat(msgs, None).await.unwrap();
552
553        let usage = tracked.get_usage().await;
554        assert_eq!(usage.prompt_tokens, 200);
555        assert_eq!(usage.completion_tokens, 40);
556        assert_eq!(usage.total_tokens, 240);
557    }
558
559    #[tokio::test]
560    async fn chat_via_dyn_base_chat_model_counts() {
561        // The agent path reaches `TokenTrackingLLM` through `dyn BaseChatModel`,
562        // so the trait impl (not the inherent method) must count.
563        let tracked = tracked_mock(
564            Some(TokenUsage {
565                prompt_tokens: 7,
566                completion_tokens: 3,
567                total_tokens: 10,
568            }),
569            false,
570        );
571        let model: &dyn BaseChatModel<Error = MockError> = &tracked;
572        model.chat(vec![Message::human("hi")], None).await.unwrap();
573
574        let usage = tracked.get_usage().await;
575        assert_eq!(usage.prompt_tokens, 7);
576        assert_eq!(usage.completion_tokens, 3);
577    }
578
579    #[tokio::test]
580    async fn chat_estimates_when_provider_reports_no_usage() {
581        let tracked = tracked_mock(None, false);
582        tracked
583            .chat(vec![Message::human("hello world")], None)
584            .await
585            .unwrap();
586
587        let usage = tracked.get_usage().await;
588        assert!(usage.prompt_tokens > 0, "prompt should be estimated");
589        assert!(
590            usage.completion_tokens > 0,
591            "completion should be estimated"
592        );
593    }
594
595    #[tokio::test]
596    async fn stream_chat_accumulates_real_usage() {
597        let llm = MockChatModel {
598            chat_usage: None,
599            stream_usage: Some(TokenUsage {
600                prompt_tokens: 50,
601                completion_tokens: 15,
602                total_tokens: 65,
603            }),
604            tool_capable: false,
605        };
606        let tracked = TokenTrackingLLM::new(llm, Arc::new(CharRatioCounter::new(4)));
607
608        let stream = tracked
609            .stream_chat(vec![Message::human("hi")], None)
610            .await
611            .unwrap();
612        let items: Vec<_> = stream.collect().await;
613        assert_eq!(items.len(), 2);
614        assert!(items[0].is_ok());
615
616        let usage = tracked.get_usage().await;
617        assert_eq!(usage.prompt_tokens, 50);
618        assert_eq!(usage.completion_tokens, 15);
619    }
620
621    #[tokio::test]
622    async fn runnable_stream_counts_through_stream_chat() {
623        let llm = MockChatModel {
624            chat_usage: None,
625            stream_usage: Some(TokenUsage {
626                prompt_tokens: 5,
627                completion_tokens: 5,
628                total_tokens: 10,
629            }),
630            tool_capable: false,
631        };
632        let tracked = TokenTrackingLLM::new(llm, Arc::new(CharRatioCounter::new(4)));
633
634        let stream = tracked
635            .stream(vec![Message::human("hi")], None)
636            .await
637            .unwrap();
638        let items: Vec<_> = stream.collect().await;
639        assert_eq!(items.len(), 2);
640        assert!(items[0].is_ok());
641
642        let usage = tracked.get_usage().await;
643        assert_eq!(usage.total_tokens, 10);
644    }
645
646    #[tokio::test]
647    async fn bind_tools_keeps_shared_usage() {
648        // bind_tools re-wraps the boxed model in a `TokenTrackingLLM` sharing the
649        // same usage `Arc`; counting must continue after tools are attached.
650        let tracked = tracked_mock(
651            Some(TokenUsage {
652                prompt_tokens: 10,
653                completion_tokens: 4,
654                total_tokens: 14,
655            }),
656            true,
657        );
658
659        let bound = tracked
660            .bind_tools(vec![ToolDefinition::new(
661                "get_weather",
662                "Get current weather",
663            )])
664            .expect("tool-capable mock must bind");
665        bound.chat(vec![Message::human("hi")], None).await.unwrap();
666
667        let usage = tracked.get_usage().await;
668        assert_eq!(usage.prompt_tokens, 10);
669        assert_eq!(usage.completion_tokens, 4);
670    }
671
672    #[test]
673    fn bind_tools_returns_none_when_model_incapable() {
674        let tracked = tracked_mock(None, false);
675        assert!(tracked
676            .bind_tools(vec![ToolDefinition::new("t", "t")])
677            .is_none());
678    }
679
680    #[test]
681    fn base_model_metadata_passthrough() {
682        let tracked = tracked_mock(None, false);
683        assert_eq!(tracked.model_name(), "mock-model");
684        // "hello world" = 11 bytes, / 4 = 2
685        assert_eq!(tracked.get_num_tokens("hello world"), 2);
686    }
687
688    #[tokio::test]
689    async fn with_temperature_preserves_usage() {
690        let tracked = tracked_mock(
691            Some(TokenUsage {
692                prompt_tokens: 3,
693                completion_tokens: 1,
694                total_tokens: 4,
695            }),
696            false,
697        );
698        let tracked = tracked.with_temperature(0.5).with_max_tokens(128);
699        tracked
700            .chat(vec![Message::human("hi")], None)
701            .await
702            .unwrap();
703
704        let usage = tracked.get_usage().await;
705        assert_eq!(usage.prompt_tokens, 3);
706        assert_eq!(usage.completion_tokens, 1);
707    }
708
709    // --- v0.20.2: observability sink ---------------------------------------
710
711    /// Mock sink recording events in a shared buffer.
712    struct MockSink {
713        events: Arc<Mutex<Vec<ObsEvent>>>,
714        fail: bool,
715    }
716
717    #[async_trait]
718    impl MetricsSink for MockSink {
719        async fn export(&self, event: &ObsEvent) -> Result<(), ObsError> {
720            if self.fail {
721                return Err(ObsError::Transport("mock failure".to_string()));
722            }
723            self.events.lock().await.push(event.clone());
724            Ok(())
725        }
726    }
727
728    fn tracked_mock_with_sink(
729        tool_capable: bool,
730        events: Arc<Mutex<Vec<ObsEvent>>>,
731    ) -> (TokenTrackingLLM<MockChatModel>, Arc<Mutex<Vec<ObsEvent>>>) {
732        let tracked = tracked_mock(
733            Some(TokenUsage {
734                prompt_tokens: 100,
735                completion_tokens: 20,
736                total_tokens: 120,
737            }),
738            tool_capable,
739        )
740        .with_metrics_sink(Arc::new(MockSink {
741            events: events.clone(),
742            fail: false,
743        }));
744        (tracked, events)
745    }
746
747    #[tokio::test]
748    async fn with_metrics_sink_exports_token_usage_after_chat() {
749        let events = Arc::new(Mutex::new(Vec::new()));
750        let (tracked, events) = tracked_mock_with_sink(false, events);
751
752        tracked
753            .chat(vec![Message::human("hi")], None)
754            .await
755            .unwrap();
756
757        let captured = events.lock().await;
758        assert_eq!(captured.len(), 1);
759        match &captured[0] {
760            ObsEvent::TokenUsage(u) => {
761                assert_eq!(u.prompt_tokens, 100);
762                assert_eq!(u.completion_tokens, 20);
763                assert_eq!(u.total_tokens, 120);
764            }
765            ObsEvent::AgentMetrics(_) => panic!("unexpected event kind"),
766            ObsEvent::Cost(_) => panic!("unexpected event kind"),
767        }
768    }
769
770    #[tokio::test]
771    async fn stream_chat_exports_usage_when_sink_attached() {
772        let events = Arc::new(Mutex::new(Vec::new()));
773        let llm = MockChatModel {
774            chat_usage: None,
775            stream_usage: Some(TokenUsage {
776                prompt_tokens: 50,
777                completion_tokens: 15,
778                total_tokens: 65,
779            }),
780            tool_capable: false,
781        };
782        let tracked = TokenTrackingLLM::new(llm, Arc::new(CharRatioCounter::new(4)))
783            .with_metrics_sink(Arc::new(MockSink {
784                events: events.clone(),
785                fail: false,
786            }));
787
788        let stream = tracked
789            .stream_chat(vec![Message::human("hi")], None)
790            .await
791            .unwrap();
792        let items: Vec<_> = stream.collect().await;
793        assert_eq!(items.len(), 2);
794        assert!(items[0].is_ok());
795
796        let captured = events.lock().await;
797        assert_eq!(captured.len(), 1);
798        match &captured[0] {
799            ObsEvent::TokenUsage(u) => assert_eq!(u.total_tokens, 65),
800            ObsEvent::AgentMetrics(_) => panic!("unexpected event kind"),
801            ObsEvent::Cost(_) => panic!("unexpected event kind"),
802        }
803    }
804
805    #[tokio::test]
806    async fn sink_export_failure_is_warned_and_flow_continues() {
807        let tracked = tracked_mock(
808            Some(TokenUsage {
809                prompt_tokens: 10,
810                completion_tokens: 4,
811                total_tokens: 14,
812            }),
813            false,
814        )
815        .with_metrics_sink(Arc::new(MockSink {
816            events: Arc::new(Mutex::new(Vec::new())),
817            fail: true,
818        }));
819
820        let result = tracked.chat(vec![Message::human("hi")], None).await;
821        assert!(result.is_ok(), "export failure must not propagate");
822
823        let usage = tracked.get_usage().await;
824        assert_eq!(usage.total_tokens, 14);
825    }
826
827    #[tokio::test]
828    async fn bind_tools_keeps_metrics_sink_attached() {
829        let events = Arc::new(Mutex::new(Vec::new()));
830        let (tracked, events) = tracked_mock_with_sink(true, events);
831
832        let bound = tracked
833            .bind_tools(vec![ToolDefinition::new(
834                "get_weather",
835                "Get current weather",
836            )])
837            .expect("tool-capable mock must bind");
838        bound.chat(vec![Message::human("hi")], None).await.unwrap();
839
840        let captured = events.lock().await;
841        assert_eq!(captured.len(), 1);
842        match &captured[0] {
843            ObsEvent::TokenUsage(u) => assert_eq!(u.prompt_tokens, 100),
844            ObsEvent::AgentMetrics(_) => panic!("unexpected event kind"),
845            ObsEvent::Cost(_) => panic!("unexpected event kind"),
846        }
847    }
848
849    // --- B3: CostTracker integration ---------------------------------------
850
851    use crate::cost::{CostTracker, ModelPrice, PricingTable};
852
853    #[tokio::test]
854    async fn chat_prices_calls_on_shared_cost_tracker() {
855        let table = PricingTable::new().with("mock", "mock-model", ModelPrice::new(2.0, 8.0));
856        let tracker = Arc::new(CostTracker::new(Arc::new(table)));
857        let tracked = tracked_mock(
858            Some(TokenUsage {
859                prompt_tokens: 1000,
860                completion_tokens: 500,
861                total_tokens: 1500,
862            }),
863            false,
864        )
865        .with_provider("mock")
866        .with_cost_tracker(tracker.clone());
867
868        tracked
869            .chat(vec![Message::human("hi")], None)
870            .await
871            .unwrap();
872        tracked
873            .chat(vec![Message::human("hi")], None)
874            .await
875            .unwrap();
876
877        // 2 * (1000*2/1k + 500*8/1k) = 2 * 6 = 12
878        assert_eq!(tracker.total_cost_usd().await, 12.0);
879        let report = tracker.report().await;
880        assert_eq!(report.calls, 2);
881        assert_eq!(report.by_model["mock/mock-model"].cost_usd, 12.0);
882    }
883
884    #[tokio::test]
885    async fn stream_prices_terminal_usage_on_cost_tracker() {
886        let table = PricingTable::new().with("mock", "mock-model", ModelPrice::new(1.0, 2.0));
887        let tracker = Arc::new(CostTracker::new(Arc::new(table)));
888        let llm = MockChatModel {
889            chat_usage: None,
890            stream_usage: Some(TokenUsage {
891                prompt_tokens: 1000,
892                completion_tokens: 1000,
893                total_tokens: 2000,
894            }),
895            tool_capable: false,
896        };
897        let tracked = TokenTrackingLLM::new(llm, Arc::new(CharRatioCounter::new(4)))
898            .with_provider("mock")
899            .with_cost_tracker(tracker.clone());
900
901        let stream = tracked
902            .stream_chat(vec![Message::human("hi")], None)
903            .await
904            .unwrap();
905        let _: Vec<_> = stream.collect().await;
906
907        // 1000*1/1k + 1000*2/1k = 3
908        assert_eq!(tracker.total_cost_usd().await, 3.0);
909    }
910
911    #[tokio::test]
912    async fn cost_tracker_survives_temperature_and_tool_rebuilds() {
913        // "mock-model" is not in the built-in table: calls count but price zero.
914        let tracker = Arc::new(CostTracker::with_builtin_prices());
915        let tracked = tracked_mock(
916            Some(TokenUsage {
917                prompt_tokens: 10,
918                completion_tokens: 10,
919                total_tokens: 20,
920            }),
921            true,
922        )
923        .with_cost_tracker(tracker.clone());
924
925        let rebuilt = tracked.with_temperature(0.1).with_max_tokens(64);
926        rebuilt
927            .chat(vec![Message::human("hi")], None)
928            .await
929            .unwrap();
930        let bound = rebuilt
931            .bind_tools(vec![ToolDefinition::new("t", "t")])
932            .unwrap();
933        bound.chat(vec![Message::human("hi")], None).await.unwrap();
934
935        // Unpriced "mock-model" => cost 0, but both rebuilt wrappers count.
936        assert_eq!(tracker.report().await.calls, 2);
937        assert_eq!(tracker.total_cost_usd().await, 0.0);
938    }
939}