Coverage for /home/admin/Documents/AI/applications/lexigram-dev/lexigram/experimental/ai/lexigram-ai-llm/src/lexigram/ai/llm/pricing/tokens.py: 41%
133 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 07:19 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 07:19 +0800
1"""Token counting and cost estimation utilities.
3Example:
4 >>> from lexigram.ai.llm import TiktokenCounter
5 >>>
6 >>> counter = TiktokenCounter(model="gpt-4")
7 >>> tokens = counter.count("Hello, world!")
8 >>> print(f"Tokens: {tokens}")
10"""
12from __future__ import annotations
14from dataclasses import dataclass
15from datetime import UTC, datetime
16from typing import Any
18from lexigram.ai.llm.types import ChatMessage
19from lexigram.domain import DomainModel
20from lexigram.logging import (
21 get_logger,
22)
23from lexigram.validation import Field
25logger = get_logger(__name__)
28@dataclass(init=False)
29class TokenCount(DomainModel):
30 """Token count result with metadata.
32 Attributes:
33 total: Total number of tokens.
34 prompt_tokens: Number of tokens in the prompt.
35 completion_tokens: Number of tokens in the completion (if applicable).
36 model: Model name used for counting.
37 timestamp: When the count was performed.
39 """
41 total: int = Field(..., description="Total token count")
42 prompt_tokens: int = Field(default=0, description="Prompt token count")
43 completion_tokens: int = Field(default=0, description="Completion token count")
44 model: str = Field(..., description="Model used for counting")
45 timestamp: datetime = Field(
46 default_factory=lambda: datetime.now(UTC),
47 description="Count timestamp",
48 )
51@dataclass(init=False)
52class CostEstimate(DomainModel):
53 """Cost estimation result.
55 Attributes:
56 prompt_cost: Cost for prompt tokens.
57 completion_cost: Cost for completion tokens.
58 total_cost: Total estimated cost.
59 currency: Currency code (default: USD).
60 model: Model name.
61 rate_per_1k_prompt: Rate per 1000 prompt tokens.
62 rate_per_1k_completion: Rate per 1000 completion tokens.
64 """
66 prompt_cost: float = Field(..., description="Prompt token cost")
67 completion_cost: float = Field(..., description="Completion token cost")
68 total_cost: float = Field(..., description="Total cost")
69 currency: str = Field(default="USD", description="Currency code")
70 model: str = Field(..., description="Model name")
71 rate_per_1k_prompt: float = Field(..., description="Rate per 1k prompt tokens")
72 rate_per_1k_completion: float = Field(
73 ...,
74 description="Rate per 1k completion tokens",
75 )
78class TiktokenCounter:
79 """Token counter using tiktoken (OpenAI/compatible models).
81 Implements TokenCounterProtocol using tiktoken for precise counting.
82 tiktoken is a required dependency for this counter.
84 Args:
85 model: Model name (e.g. 'gpt-4', 'gpt-3.5-turbo').
86 encoding_name: Optional tiktoken encoding name override.
87 """
89 def __init__(
90 self,
91 model: str = "gpt-3.5-turbo",
92 encoding_name: str | None = None,
93 ) -> None:
94 """Initialize TiktokenCounter.
96 Args:
97 model: Model name for token counting.
98 encoding_name: Optional tiktoken encoding name override.
100 Raises:
101 ImportError: If tiktoken is not installed.
102 """
103 import tiktoken
105 self._model = model
106 self._encoding_name = encoding_name
107 self._encoder: Any | None = None
108 self._tiktoken = tiktoken
110 @property
111 def model(self) -> str:
112 """The model this counter is calibrated for."""
113 return self._model
115 def _get_encoder(self) -> Any:
116 """Get tiktoken encoder lazily."""
117 if self._encoder is None:
118 try:
119 if self._encoding_name:
120 self._encoder = self._tiktoken.get_encoding(self._encoding_name)
121 else:
122 self._encoder = self._tiktoken.encoding_for_model(self._model)
123 except (KeyError, ValueError):
124 self._encoder = self._tiktoken.get_encoding("cl100k_base")
125 return self._encoder
127 def count(self, text: str) -> int:
128 """Count tokens in a text string."""
129 encoder = self._get_encoder()
130 return len(encoder.encode(text))
132 def count_messages(self, messages: list[ChatMessage]) -> int:
133 """Count tokens in a list of chat messages, including overhead."""
134 encoder = self._get_encoder()
135 tokens_per_message = 3
136 tokens_per_name = 1
137 num_tokens = 0
138 for msg in messages:
139 num_tokens += tokens_per_message
140 num_tokens += len(encoder.encode(str(msg.content or "")))
141 if hasattr(msg, "role"):
142 num_tokens += len(encoder.encode(str(msg.role)))
143 if hasattr(msg, "name") and msg.name:
144 num_tokens += tokens_per_name
145 num_tokens += 3
146 return num_tokens
149class HuggingFaceCounter:
150 """Token counter using HuggingFace AutoTokenizer (lazy-loaded).
152 When constructed without a model, uses character estimation (~4 chars/token).
153 When constructed with a model name, lazy-loads that model's tokenizer on first use.
155 Args:
156 model: Optional HuggingFace model name. If None, uses char estimation fallback.
157 """
159 def __init__(self, model: str | None = None) -> None:
160 """Initialize HuggingFaceCounter.
162 Args:
163 model: Optional HuggingFace model name for tokenizer loading.
164 """
165 self._model = model
166 self._tokenizer: Any | None = None
167 self._loaded: bool = False
169 @property
170 def model(self) -> str:
171 """Backend identifier."""
172 return self._model or "huggingface"
174 def _get_tokenizer(self) -> Any | None:
175 """Lazily load tokenizer on first use."""
176 if self._model is None:
177 return None # Use char estimation
178 if not self._loaded:
179 self._loaded = True
180 try:
181 from transformers import AutoTokenizer
183 self._tokenizer = AutoTokenizer.from_pretrained(
184 self._model,
185 use_fast=True,
186 )
187 except ImportError as e:
188 logger.warning("huggingface_counter_import_failed", error=str(e))
189 except OSError as e:
190 logger.warning(
191 "huggingface_counter_load_failed",
192 model=self._model,
193 error=str(e),
194 )
195 return self._tokenizer
197 def count(self, text: str) -> int:
198 """Count tokens in a text string."""
199 tok = self._get_tokenizer()
200 if tok is None:
201 return max(1, len(text) // 4)
202 return len(tok.encode(text))
204 def count_messages(self, messages: list[ChatMessage]) -> int:
205 """Count tokens in a list of chat messages."""
206 return (
207 sum(self.count(str(m.content or "")) for m in messages) + len(messages) * 4
208 )
211class MistralCounter:
212 """Token counter using mistral-common tokenizer (lazy-loaded).
214 Tokenizer is loaded on first use, not at construction time.
215 """
217 def __init__(self) -> None:
218 """Initialize MistralCounter."""
219 self._tokenizer: Any | None = None
220 self._loaded: bool = False
222 @property
223 def model(self) -> str:
224 """Backend identifier."""
225 return "mistral"
227 def _get_tokenizer(self) -> Any | None:
228 """Lazily load tokenizer on first use."""
229 if not self._loaded:
230 self._loaded = True
231 try:
232 from mistral_common.tokens.tokenizers.mistral import (
233 MistralTokenizer,
234 )
236 self._tokenizer = MistralTokenizer.v3()
237 except ImportError as e:
238 logger.warning("mistral_counter_import_failed", error=str(e))
239 except Exception as e: # noqa: BLE001 — tokenizer init raises varied errors (OS, network, parse)
240 logger.warning("mistral_counter_load_failed", error=str(e))
241 return self._tokenizer
243 def count(self, text: str) -> int:
244 """Count tokens in a text string."""
245 tok = self._get_tokenizer()
246 if tok is None:
247 return max(1, len(text) // 4)
248 try:
249 # Try the instruct_tokenizer path (mistral-common v1+)
250 encoded = tok.instruct_tokenizer.tokenizer.encode(
251 text, bos=False, eos=False
252 )
253 return len(encoded)
254 except AttributeError:
255 # Fallback: try direct encode (older API or alternate path)
256 try:
257 return len(tok.encode(text))
258 except AttributeError:
259 return max(1, len(text) // 4)
261 def count_messages(self, messages: list[ChatMessage]) -> int:
262 """Count tokens in a list of chat messages."""
263 return (
264 sum(self.count(str(m.content or "")) for m in messages) + len(messages) * 4
265 )
268class CharEstimateCounter:
269 """Character-based token count estimator (~4 chars per token).
271 Always available without any optional dependencies.
272 Suitable as a safe fallback counter.
274 Args:
275 model: Model name (used for identification only).
276 """
278 def __init__(self, model: str = "unknown") -> None:
279 """Initialize CharEstimateCounter.
281 Args:
282 model: Model name for identification.
283 """
284 self._model = model
286 @property
287 def model(self) -> str:
288 """The model this counter is calibrated for."""
289 return self._model
291 def count(self, text: str) -> int:
292 """Count tokens using character estimation."""
293 return max(1, len(text) // 4)
295 def count_messages(self, messages: list[ChatMessage]) -> int:
296 """Count tokens in a list of chat messages."""
297 return (
298 sum(self.count(str(m.content or "")) for m in messages) + len(messages) * 4
299 )
302def create_token_counter(
303 model: str = "gpt-3.5-turbo",
304 encoding_name: str | None = None,
305) -> TiktokenCounter:
306 """Factory function for creating token counters.
308 Args:
309 model: Model name.
310 encoding_name: Optional encoding name override.
312 Returns:
313 TiktokenCounter instance.
315 Example:
316 >>> from lexigram.ai.llm import create_token_counter
317 >>>
318 >>> counter = create_token_counter("gpt-4")
319 >>> count = counter.count("Hello!")
320 >>> print(count)
322 """
323 return TiktokenCounter(
324 model=model,
325 encoding_name=encoding_name,
326 )