Skip to content

Commit ad1ccbb

Browse files
committed
feat: add token counters
1 parent efc8bd1 commit ad1ccbb

13 files changed

Lines changed: 638 additions & 1 deletion
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
2+
#
3+
# SPDX-License-Identifier: Apache-2.0
4+
5+
import sys
6+
from typing import TYPE_CHECKING
7+
8+
from lazy_imports import LazyImporter
9+
10+
_import_structure = {
11+
"approximate_counter": ["ApproximateTokenCounter"],
12+
"types": ["TokenCounter"],
13+
"tiktoken_counter": ["TiktokenCounter"],
14+
}
15+
16+
if TYPE_CHECKING:
17+
from .approximate_counter import ApproximateTokenCounter as ApproximateTokenCounter
18+
from .tiktoken_counter import TiktokenCounter as TiktokenCounter
19+
from .types import TokenCounter as TokenCounter
20+
else:
21+
sys.modules[__name__] = LazyImporter(name=__name__, module_file=__file__, import_structure=_import_structure)
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
2+
#
3+
# SPDX-License-Identifier: Apache-2.0
4+
5+
from typing import Any
6+
7+
from haystack.core.serialization import default_to_dict
8+
from haystack.dataclasses import ChatMessage
9+
from haystack.token_counters.types import TokenCounter
10+
from haystack.token_counters.utils import _non_text_tokens, _rendered_conversation
11+
12+
13+
class ApproximateTokenCounter(TokenCounter):
14+
"""
15+
Estimates tokens from text length using a flat ratio of characters to tokens.
16+
17+
## Usage Example:
18+
```python
19+
from haystack.dataclasses import ChatMessage
20+
from haystack.token_counters import ApproximateTokenCounter
21+
22+
counter = ApproximateTokenCounter(chars_per_token=4.0)
23+
messages = [
24+
ChatMessage.from_user("Hello, how are you?"),
25+
ChatMessage.from_assistant("I'm good, thank you! How can I assist you today?")
26+
]
27+
token_count = counter.count(messages)
28+
print(f"Estimated token count: {token_count}")
29+
```
30+
"""
31+
32+
def __init__(self, chars_per_token: float = 4.0, tokens_per_image: int = 85, tokens_per_file: int = 1000) -> None:
33+
"""
34+
Initialize the counter.
35+
36+
:param chars_per_token: How many characters to treat as one token.
37+
:param tokens_per_image: Tokens to charge per image, which has no text to measure. The default is what
38+
OpenAI charges for a small image; raise it if you send large ones.
39+
:param tokens_per_file: Tokens to charge per file. A rough stand-in for a short document, since the real
40+
cost depends on the page count; raise it if you send long ones.
41+
:raises ValueError: If `chars_per_token` is not positive.
42+
"""
43+
if chars_per_token <= 0:
44+
raise ValueError(f"`chars_per_token` must be greater than 0, got {chars_per_token}.")
45+
self.chars_per_token = chars_per_token
46+
self.tokens_per_image = tokens_per_image
47+
self.tokens_per_file = tokens_per_file
48+
49+
def count(self, messages: list[ChatMessage]) -> int:
50+
"""
51+
Return the estimated number of tokens the given messages occupy.
52+
53+
:param messages: The messages to measure.
54+
:returns: The estimated token count, or `0` for an empty list.
55+
"""
56+
if not messages:
57+
return 0
58+
text_tokens = int(len(_rendered_conversation(messages)) / self.chars_per_token)
59+
return text_tokens + _non_text_tokens(messages, self.tokens_per_image, self.tokens_per_file)
60+
61+
def to_dict(self) -> dict[str, Any]:
62+
"""
63+
Serialize the counter.
64+
65+
:returns: A dictionary representation of the counter.
66+
"""
67+
return default_to_dict(
68+
self,
69+
chars_per_token=self.chars_per_token,
70+
tokens_per_image=self.tokens_per_image,
71+
tokens_per_file=self.tokens_per_file,
72+
)
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
2+
#
3+
# SPDX-License-Identifier: Apache-2.0
4+
5+
from typing import Any
6+
7+
from haystack.core.serialization import default_to_dict
8+
from haystack.dataclasses import ChatMessage
9+
from haystack.lazy_imports import LazyImport
10+
from haystack.token_counters.types import TokenCounter
11+
from haystack.token_counters.utils import _non_text_tokens, _rendered_conversation
12+
13+
with LazyImport("Run 'pip install tiktoken'") as tiktoken_imports:
14+
import tiktoken
15+
16+
17+
class TiktokenCounter(TokenCounter):
18+
"""
19+
Counts tokens locally with `tiktoken`, OpenAI's byte-pair encoder.
20+
21+
Counting is an estimate, and two limits are worth knowing before relying on it:
22+
- **It is text-only**, so images and files get the flat `tokens_per_image` / `tokens_per_file` estimate rather
23+
than a real count.
24+
- **It is OpenAI's encoder.** Other providers tokenize differently, so expect the count to drift on them.
25+
26+
## Usage Example:
27+
```python
28+
from haystack.dataclasses import ChatMessage
29+
from haystack.token_counters import TiktokenCounter
30+
31+
counter = TiktokenCounter(encoding="o200k_base")
32+
messages = [
33+
ChatMessage.from_user("Hello, how are you?"),
34+
ChatMessage.from_assistant("I'm good, thank you! How can I assist you today?")
35+
]
36+
token_count = counter.count(messages)
37+
print(f"Token count: {token_count}")
38+
```
39+
"""
40+
41+
def __init__(self, encoding: str = "o200k_base", tokens_per_image: int = 85, tokens_per_file: int = 1000) -> None:
42+
"""
43+
Initialize the counter.
44+
45+
:param encoding: The `tiktoken` encoding to count with. The default, `o200k_base`, is what current OpenAI
46+
models use.
47+
:param tokens_per_image: Tokens to charge per image, which the tokenizer cannot measure. The default is what
48+
OpenAI charges for a small image; raise it if you send large ones.
49+
:param tokens_per_file: Tokens to charge per file. A rough stand-in for a short document, since the real
50+
cost depends on the page count; raise it if you send long ones.
51+
:raises ImportError: If `tiktoken` is not installed.
52+
"""
53+
tiktoken_imports.check()
54+
self.encoding = encoding
55+
self.tokens_per_image = tokens_per_image
56+
self.tokens_per_file = tokens_per_file
57+
self._encoder: Any = None
58+
59+
def warm_up(self) -> None:
60+
"""Load the encoder, downloading its vocabulary if it is not already cached."""
61+
if self._encoder is not None:
62+
return
63+
self._encoder = tiktoken.get_encoding(self.encoding)
64+
65+
def count(self, messages: list[ChatMessage]) -> int:
66+
"""
67+
Return the estimated number of tokens used by the given messages.
68+
69+
:param messages: The messages to measure.
70+
:returns: The estimated token count, or `0` for an empty list.
71+
"""
72+
if not messages:
73+
return 0
74+
self.warm_up()
75+
text_tokens = len(self._encoder.encode(_rendered_conversation(messages)))
76+
return text_tokens + _non_text_tokens(messages, self.tokens_per_image, self.tokens_per_file)
77+
78+
def to_dict(self) -> dict[str, Any]:
79+
"""
80+
Serialize the counter.
81+
82+
:returns: A dictionary representation of the counter.
83+
"""
84+
return default_to_dict(
85+
self, encoding=self.encoding, tokens_per_image=self.tokens_per_image, tokens_per_file=self.tokens_per_file
86+
)
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
2+
#
3+
# SPDX-License-Identifier: Apache-2.0
4+
5+
from .protocol import TokenCounter
6+
7+
__all__ = ["TokenCounter"]
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
2+
#
3+
# SPDX-License-Identifier: Apache-2.0
4+
5+
from typing import Any, Protocol
6+
7+
from haystack.core.serialization import default_from_dict, default_to_dict
8+
from haystack.dataclasses import ChatMessage
9+
10+
11+
class TokenCounter(Protocol):
12+
"""
13+
Estimates the number tokens used by a list of messages.
14+
15+
Override `to_dict` when the counter takes constructor arguments, so that they are serialized; the default emits
16+
none. `from_dict` rebuilds the counter from whatever `to_dict` emitted and rarely needs overriding.
17+
"""
18+
19+
def count(self, messages: list[ChatMessage]) -> int:
20+
"""
21+
Return the estimated number of tokens in the given messages.
22+
23+
:param messages: The messages to measure.
24+
:returns: The estimated token count.
25+
"""
26+
...
27+
28+
def to_dict(self) -> dict[str, Any]:
29+
"""Serialize the counter to a dictionary."""
30+
return default_to_dict(self)
31+
32+
@classmethod
33+
def from_dict(cls, data: dict[str, Any]) -> "TokenCounter":
34+
"""Deserialize the counter from a dictionary."""
35+
return default_from_dict(cls, data)

haystack/token_counters/utils.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
2+
#
3+
# SPDX-License-Identifier: Apache-2.0
4+
5+
import json
6+
7+
from haystack.dataclasses import ChatMessage, FileContent, ImageContent, TextContent
8+
from haystack.dataclasses.chat_message import ChatMessageContentT, ToolCallResultContentT
9+
10+
11+
def _non_text_placeholder(content: ChatMessageContentT) -> str:
12+
"""A short stand-in, such as `<image>`, for message content that has no text form."""
13+
if isinstance(content, ImageContent):
14+
return "<image>"
15+
if isinstance(content, FileContent):
16+
return f"<file: {content.filename or 'unnamed'}>"
17+
return f"<{type(content).__name__}>"
18+
19+
20+
def _tool_result_text(result: ToolCallResultContentT) -> str:
21+
"""A tool result as a single string, with placeholders standing in for any non-text parts."""
22+
if isinstance(result, str):
23+
return result
24+
return "".join(block.text if isinstance(block, TextContent) else _non_text_placeholder(block) for block in result)
25+
26+
27+
def _render_message(message: ChatMessage) -> str:
28+
"""
29+
One message as one or more lines of plain text.
30+
31+
Reasoning content is deliberately left out: providers discard it between turns, so it is not part of the context
32+
being measured.
33+
"""
34+
role = message.role.value
35+
# A tool-result msg only carries tool_call_results, so it is rendered on its own and labelled with the tool that
36+
# produced it.
37+
if results := message.tool_call_results:
38+
return "\n".join(
39+
f"[tool:{result.origin.tool_name}{' (error)' if result.error else ''}] {_tool_result_text(result.result)}"
40+
for result in results
41+
)
42+
43+
lines: list[str] = []
44+
if texts := message.texts:
45+
lines.append(f"[{role}] " + "\n".join(texts))
46+
for call in message.tool_calls:
47+
arguments = json.dumps(call.arguments, default=str, sort_keys=True)
48+
lines.append(f"[{role} -> tool_call] {call.tool_name}({arguments})")
49+
# Images and files cost tokens too, so they need a stand-in rather than being skipped.
50+
non_text: list[ChatMessageContentT] = [*message.images, *message.files]
51+
for content in non_text:
52+
lines.append(f"[{role}] {_non_text_placeholder(content)}")
53+
return "\n".join(lines) if lines else f"[{role}] <no content>"
54+
55+
56+
def _rendered_conversation(messages: list[ChatMessage]) -> str:
57+
"""The whole conversation as one plain-text block, which is what a counter measures."""
58+
return "\n".join(_render_message(message) for message in messages)
59+
60+
61+
def _non_text_tokens(messages: list[ChatMessage], tokens_per_image: int, tokens_per_file: int) -> int:
62+
"""
63+
A flat estimate for the images and files in a conversation, which have no text to measure.
64+
65+
:param messages: The conversation to measure.
66+
:param tokens_per_image: Tokens to charge per image.
67+
:param tokens_per_file: Tokens to charge per file.
68+
:returns: The estimated token count for the non-text content.
69+
"""
70+
images = 0
71+
files = 0
72+
for message in messages:
73+
images += len(message.images)
74+
files += len(message.files)
75+
# Images and files returned by a tool are nested inside the tool result
76+
for result in message.tool_call_results:
77+
if isinstance(result.result, str):
78+
continue
79+
images += sum(isinstance(block, ImageContent) for block in result.result)
80+
files += sum(isinstance(block, FileContent) for block in result.result)
81+
return images * tokens_per_image + files * tokens_per_file

pydoc/token_counters_api.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
loaders:
2+
- search_path: [../haystack/token_counters]
3+
modules: ["types/protocol", "approximate_counter", "tiktoken_counter"]
4+
processors:
5+
- type: filter
6+
documented_only: true
7+
skip_empty_modules: true
8+
renderer:
9+
title: Token Counters
10+
id: token-counters-api
11+
description: Estimate how many tokens a conversation occupies, for features that need a size before sending it to a
12+
model.
13+
filename: token_counters_api.md

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ dependencies = [
113113
"python-oxmsg", # MSGToDocument
114114

115115
"nltk>=3.9.1", # NLTKDocumentSplitter, RecursiveDocumentSplitter
116-
"tiktoken", # RecursiveDocumentSplitter
116+
"tiktoken", # RecursiveDocumentSplitter, TiktokenCounter
117117

118118
# Human in the Loop
119119
"rich", # Console rendering for HITL
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
features:
3+
- |
4+
Added ``haystack.token_counters``: a ``TokenCounter`` protocol for estimating how many tokens a list of
5+
``ChatMessage`` objects occupies, with two implementations.
6+
7+
Providers report token usage only after a call, and only for the call as a whole, so anything that needs a size
8+
beforehand - deciding whether a conversation still fits a model's context window, or how much of it to drop - has to
9+
estimate one.
10+
11+
.. code-block:: python
12+
13+
from haystack.dataclasses import ChatMessage
14+
from haystack.token_counters import ApproximateTokenCounter, TiktokenCounter
15+
16+
messages = [ChatMessage.from_user("Hello, how are you?")]
17+
18+
# No dependencies: estimates from text length.
19+
ApproximateTokenCounter(chars_per_token=4.0).count(messages)
20+
21+
# Closer for OpenAI models; needs: pip install tiktoken
22+
TiktokenCounter(encoding="o200k_base").count(messages)
23+
24+
``ApproximateTokenCounter`` needs nothing installed and estimates from text length at a configurable
25+
``chars_per_token``. ``TiktokenCounter`` counts with OpenAI's byte-pair encoder, which is closer for OpenAI models
26+
but requires ``tiktoken`` and drifts on other providers; it raises at construction when the dependency is missing,
27+
and loads its encoding on first use.
28+
29+
Neither can measure an image or a file, since a tokenizer only sees text and providers derive an image's cost from
30+
its dimensions. Both charge a flat ``tokens_per_image`` and ``tokens_per_file`` instead, counting images a tool
31+
returned inside its result as well as those a message carries directly. Raise those values if you send large images
32+
or long documents.
33+
34+
Implement ``TokenCounter`` to count differently - for instance against a provider's own token-counting endpoint,
35+
which is the only way to have images counted exactly.

test/token_counters/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
2+
#
3+
# SPDX-License-Identifier: Apache-2.0

0 commit comments

Comments
 (0)