1use std::pin::Pin;
4use std::sync::Arc;
5
6use crate::language_models::{BaseLanguageModel, LLMResult, StreamChunk, TokenUsage};
7use crate::observability::{MetricsSink, ObsEvent};
8use crate::runnables::Runnable;
9use crate::tools::ToolDefinition;
10use crate::{BaseChatModel, RunnableConfig};
11use async_trait::async_trait;
12use futures_util::{Stream, StreamExt};
13use lc_schema::Message;
14use tokio::sync::Mutex;
15
16use super::counter::{TokenCounter, TrackerTokenUsage};
17use super::tiktoken::TiktokenCounter;
18use super::TokenCounterError;
19use crate::cost::CostTracker;
20
21pub struct TokenTrackingLLM<L: BaseChatModel> {
31 llm: L,
32 counter: Arc<dyn TokenCounter>,
33 usage: Arc<Mutex<TrackerTokenUsage>>,
34 metrics_sink: Option<Arc<dyn MetricsSink>>,
37 cost_tracker: Option<Arc<CostTracker>>,
39 provider: Option<String>,
42}
43
44impl<L: BaseChatModel> TokenTrackingLLM<L> {
45 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 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 pub fn with_metrics_sink(mut self, sink: Arc<dyn MetricsSink>) -> Self {
68 self.metrics_sink = Some(sink);
69 self
70 }
71
72 pub fn with_cost_tracker(mut self, tracker: Arc<CostTracker>) -> Self {
78 self.cost_tracker = Some(tracker);
79 self
80 }
81
82 pub fn with_provider(mut self, provider: impl Into<String>) -> Self {
85 self.provider = Some(provider.into());
86 self
87 }
88
89 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 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 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 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 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 pub async fn get_usage(&self) -> TrackerTokenUsage {
161 self.usage.lock().await.clone()
162 }
163
164 pub async fn reset(&self) {
166 self.usage.lock().await.reset();
167 }
168
169 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 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 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 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 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
353pub struct ModelPricing {
355 pub prompt_price_per_1k: f64,
357 pub completion_price_per_1k: f64,
359}
360
361impl ModelPricing {
362 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 pub fn gpt4o_mini() -> Self {
372 Self::new(0.15, 0.60)
373 }
374
375 pub fn gpt4o() -> Self {
377 Self::new(2.50, 10.00)
378 }
379
380 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 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 let cost = p.calculate(500, 250);
410 assert!((cost - 1.0).abs() < 0.001);
411 }
412
413 use crate::language_models::TokenUsage;
418 use crate::observability::ObsError;
419 use crate::token_counter::CharRatioCounter;
420
421 #[derive(Debug, Clone)]
423 struct MockChatModel {
424 chat_usage: Option<TokenUsage>,
426 stream_usage: Option<TokenUsage>,
428 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 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 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 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 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 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 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 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 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 assert_eq!(tracker.report().await.calls, 2);
937 assert_eq!(tracker.total_cost_usd().await, 0.0);
938 }
939}