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 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 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 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 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 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 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 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 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 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 assert_eq!(tracker.report().await.calls, 2);
936 assert_eq!(tracker.total_cost_usd().await, 0.0);
937 }
938}