|
| 1 | +import dataclasses |
1 | 2 | from copy import deepcopy |
2 | 3 | from dataclasses import dataclass |
3 | 4 |
|
|
6 | 7 |
|
7 | 8 | @dataclass |
8 | 9 | class UsageSummary: |
9 | | - llm_prompt_tokens: int = 0 |
10 | | - llm_prompt_cached_tokens: int = 0 |
| 10 | + """Usage summary for a specific model/provider combination.""" |
| 11 | + |
| 12 | + provider: str = "" |
| 13 | + """The provider name (e.g., 'openai', 'deepgram', 'elevenlabs').""" |
| 14 | + model: str = "" |
| 15 | + """The model name (e.g., 'gpt-4o', 'nova-2', 'eleven_turbo_v2').""" |
| 16 | + |
| 17 | + llm_input_tokens: int = 0 |
| 18 | + llm_input_cached_tokens: int = 0 |
11 | 19 | llm_input_audio_tokens: int = 0 |
12 | 20 | llm_input_cached_audio_tokens: int = 0 |
13 | 21 | llm_input_text_tokens: int = 0 |
14 | 22 | llm_input_cached_text_tokens: int = 0 |
15 | 23 | llm_input_image_tokens: int = 0 |
16 | 24 | llm_input_cached_image_tokens: int = 0 |
17 | | - llm_completion_tokens: int = 0 |
| 25 | + llm_output_tokens: int = 0 |
18 | 26 | llm_output_audio_tokens: int = 0 |
19 | 27 | llm_output_image_tokens: int = 0 |
20 | 28 | llm_output_text_tokens: int = 0 |
21 | 29 | tts_characters_count: int = 0 |
22 | 30 | tts_audio_duration: float = 0.0 |
23 | 31 | stt_audio_duration: float = 0.0 |
24 | 32 |
|
25 | | - # properties for naming consistency: prompt = input, completion = output |
| 33 | + # backwards-compatible property aliases |
| 34 | + @property |
| 35 | + def llm_prompt_tokens(self) -> int: |
| 36 | + return self.llm_input_tokens |
| 37 | + |
| 38 | + @llm_prompt_tokens.setter |
| 39 | + def llm_prompt_tokens(self, value: int) -> None: |
| 40 | + self.llm_input_tokens = value |
| 41 | + |
26 | 42 | @property |
27 | | - def llm_input_tokens(self) -> int: |
28 | | - return self.llm_prompt_tokens |
| 43 | + def llm_prompt_cached_tokens(self) -> int: |
| 44 | + return self.llm_input_cached_tokens |
29 | 45 |
|
30 | | - @llm_input_tokens.setter |
31 | | - def llm_input_tokens(self, value: int) -> None: |
32 | | - self.llm_prompt_tokens = value |
| 46 | + @llm_prompt_cached_tokens.setter |
| 47 | + def llm_prompt_cached_tokens(self, value: int) -> None: |
| 48 | + self.llm_input_cached_tokens = value |
33 | 49 |
|
34 | 50 | @property |
35 | | - def llm_output_tokens(self) -> int: |
36 | | - return self.llm_completion_tokens |
| 51 | + def llm_completion_tokens(self) -> int: |
| 52 | + return self.llm_output_tokens |
37 | 53 |
|
38 | | - @llm_output_tokens.setter |
39 | | - def llm_output_tokens(self, value: int) -> None: |
40 | | - self.llm_completion_tokens = value |
| 54 | + @llm_completion_tokens.setter |
| 55 | + def llm_completion_tokens(self, value: int) -> None: |
| 56 | + self.llm_output_tokens = value |
| 57 | + |
| 58 | + def to_dict(self) -> dict: |
| 59 | + """Returns a dict with only non-zero/non-empty values.""" |
| 60 | + return {k: v for k, v in dataclasses.asdict(self).items() if v} |
| 61 | + |
| 62 | + def __repr__(self) -> str: |
| 63 | + items = ", ".join(f"{k}={v!r}" for k, v in self.to_dict().items()) |
| 64 | + return f"UsageSummary({items})" |
41 | 65 |
|
42 | 66 |
|
43 | 67 | class UsageCollector: |
| 68 | + """Collects and aggregates usage metrics per model/provider combination.""" |
| 69 | + |
44 | 70 | def __init__(self) -> None: |
45 | | - self._summary = UsageSummary() |
| 71 | + self._summaries: dict[tuple[str, str], UsageSummary] = {} |
46 | 72 |
|
47 | 73 | def __call__(self, metrics: AgentMetrics) -> None: |
48 | 74 | self.collect(metrics) |
49 | 75 |
|
| 76 | + def _get_summary(self, provider: str, model: str) -> UsageSummary: |
| 77 | + """Get or create a UsageSummary for the given provider/model combination.""" |
| 78 | + key = (provider, model) |
| 79 | + if key not in self._summaries: |
| 80 | + self._summaries[key] = UsageSummary(provider=provider, model=model) |
| 81 | + return self._summaries[key] |
| 82 | + |
| 83 | + def _extract_provider_model( |
| 84 | + self, metrics: LLMMetrics | STTMetrics | TTSMetrics | RealtimeModelMetrics |
| 85 | + ) -> tuple[str, str]: |
| 86 | + """Extract provider and model from metrics metadata.""" |
| 87 | + provider = "" |
| 88 | + model = "" |
| 89 | + if metrics.metadata: |
| 90 | + provider = metrics.metadata.model_provider or "" |
| 91 | + model = metrics.metadata.model_name or "" |
| 92 | + return provider, model |
| 93 | + |
50 | 94 | def collect(self, metrics: AgentMetrics) -> None: |
51 | 95 | if isinstance(metrics, LLMMetrics): |
52 | | - self._summary.llm_prompt_tokens += metrics.prompt_tokens |
53 | | - self._summary.llm_prompt_cached_tokens += metrics.prompt_cached_tokens |
54 | | - self._summary.llm_completion_tokens += metrics.completion_tokens |
| 96 | + provider, model = self._extract_provider_model(metrics) |
| 97 | + summary = self._get_summary(provider, model) |
| 98 | + summary.llm_input_tokens += metrics.prompt_tokens |
| 99 | + summary.llm_input_cached_tokens += metrics.prompt_cached_tokens |
| 100 | + summary.llm_output_tokens += metrics.completion_tokens |
55 | 101 |
|
56 | 102 | elif isinstance(metrics, RealtimeModelMetrics): |
57 | | - self._summary.llm_prompt_tokens += metrics.input_tokens |
58 | | - self._summary.llm_prompt_cached_tokens += metrics.input_token_details.cached_tokens |
| 103 | + provider, model = self._extract_provider_model(metrics) |
| 104 | + summary = self._get_summary(provider, model) |
| 105 | + summary.llm_input_tokens += metrics.input_tokens |
| 106 | + summary.llm_input_cached_tokens += metrics.input_token_details.cached_tokens |
59 | 107 |
|
60 | | - self._summary.llm_input_text_tokens += metrics.input_token_details.text_tokens |
61 | | - self._summary.llm_input_cached_text_tokens += ( |
| 108 | + summary.llm_input_text_tokens += metrics.input_token_details.text_tokens |
| 109 | + summary.llm_input_cached_text_tokens += ( |
62 | 110 | metrics.input_token_details.cached_tokens_details.text_tokens |
63 | 111 | if metrics.input_token_details.cached_tokens_details |
64 | 112 | else 0 |
65 | 113 | ) |
66 | | - self._summary.llm_input_image_tokens += metrics.input_token_details.image_tokens |
67 | | - self._summary.llm_input_cached_image_tokens += ( |
| 114 | + summary.llm_input_image_tokens += metrics.input_token_details.image_tokens |
| 115 | + summary.llm_input_cached_image_tokens += ( |
68 | 116 | metrics.input_token_details.cached_tokens_details.image_tokens |
69 | 117 | if metrics.input_token_details.cached_tokens_details |
70 | 118 | else 0 |
71 | 119 | ) |
72 | | - self._summary.llm_input_audio_tokens += metrics.input_token_details.audio_tokens |
73 | | - self._summary.llm_input_cached_audio_tokens += ( |
| 120 | + summary.llm_input_audio_tokens += metrics.input_token_details.audio_tokens |
| 121 | + summary.llm_input_cached_audio_tokens += ( |
74 | 122 | metrics.input_token_details.cached_tokens_details.audio_tokens |
75 | 123 | if metrics.input_token_details.cached_tokens_details |
76 | 124 | else 0 |
77 | 125 | ) |
78 | 126 |
|
79 | | - self._summary.llm_output_text_tokens += metrics.output_token_details.text_tokens |
80 | | - self._summary.llm_output_image_tokens += metrics.output_token_details.image_tokens |
81 | | - self._summary.llm_output_audio_tokens += metrics.output_token_details.audio_tokens |
82 | | - self._summary.llm_completion_tokens += metrics.output_tokens |
| 127 | + summary.llm_output_text_tokens += metrics.output_token_details.text_tokens |
| 128 | + summary.llm_output_image_tokens += metrics.output_token_details.image_tokens |
| 129 | + summary.llm_output_audio_tokens += metrics.output_token_details.audio_tokens |
| 130 | + summary.llm_output_tokens += metrics.output_tokens |
83 | 131 |
|
84 | 132 | elif isinstance(metrics, TTSMetrics): |
85 | | - self._summary.tts_characters_count += metrics.characters_count |
86 | | - self._summary.tts_audio_duration += metrics.audio_duration |
| 133 | + provider, model = self._extract_provider_model(metrics) |
| 134 | + summary = self._get_summary(provider, model) |
| 135 | + summary.tts_characters_count += metrics.characters_count |
| 136 | + summary.tts_audio_duration += metrics.audio_duration |
87 | 137 |
|
88 | 138 | elif isinstance(metrics, STTMetrics): |
89 | | - self._summary.stt_audio_duration += metrics.audio_duration |
| 139 | + provider, model = self._extract_provider_model(metrics) |
| 140 | + summary = self._get_summary(provider, model) |
| 141 | + summary.stt_audio_duration += metrics.audio_duration |
90 | 142 |
|
91 | | - def get_summary(self) -> UsageSummary: |
92 | | - return deepcopy(self._summary) |
| 143 | + def get_summary(self) -> list[UsageSummary]: |
| 144 | + """Returns a list of usage summaries, one per model/provider combination.""" |
| 145 | + return [deepcopy(s) for s in self._summaries.values()] |
0 commit comments