Skip to content

Commit f452038

Browse files
authored
feat: add token counters (#12195)
1 parent 33f00d0 commit f452038

13 files changed

Lines changed: 766 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: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
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, _rendered_tools
11+
from haystack.tools import ToolsType
12+
13+
14+
class ApproximateTokenCounter(TokenCounter):
15+
"""
16+
Estimates tokens from text length using a flat ratio of characters to tokens.
17+
18+
## Usage Example:
19+
```python
20+
from haystack.dataclasses import ChatMessage
21+
from haystack.token_counters import ApproximateTokenCounter
22+
23+
counter = ApproximateTokenCounter(chars_per_token=4.0)
24+
messages = [
25+
ChatMessage.from_user("Hello, how are you?"),
26+
ChatMessage.from_assistant("I'm good, thank you! How can I assist you today?")
27+
]
28+
token_count = counter.count(messages)
29+
print(f"Estimated token count: {token_count}")
30+
```
31+
"""
32+
33+
def __init__(self, chars_per_token: float = 4.0, tokens_per_image: int = 85, tokens_per_file: int = 1000) -> None:
34+
"""
35+
Initialize the counter.
36+
37+
:param chars_per_token: How many characters to treat as one token.
38+
:param tokens_per_image: Tokens to charge per image, which has no text to measure. The default is what
39+
OpenAI charges for a small image; raise it if you send large ones.
40+
:param tokens_per_file: Tokens to charge per file. A rough stand-in for a short document, since the real
41+
cost depends on the page count; raise it if you send long ones.
42+
:raises ValueError: If `chars_per_token` is not positive.
43+
"""
44+
if chars_per_token <= 0:
45+
raise ValueError(f"`chars_per_token` must be greater than 0, got {chars_per_token}.")
46+
self.chars_per_token = chars_per_token
47+
self.tokens_per_image = tokens_per_image
48+
self.tokens_per_file = tokens_per_file
49+
50+
def count(self, messages: list[ChatMessage], tools: ToolsType | None = None) -> int:
51+
"""
52+
Return the estimated number of tokens the given messages occupy.
53+
54+
:param messages: The messages to measure.
55+
:param tools: Tools whose schemas are sent alongside the messages, and so consume tokens too.
56+
:returns: The estimated token count, or `0` when there is nothing to measure.
57+
"""
58+
if not messages and not tools:
59+
return 0
60+
text = _rendered_conversation(messages) + _rendered_tools(tools)
61+
text_tokens = int(len(text) / self.chars_per_token)
62+
return text_tokens + _non_text_tokens(
63+
messages=messages, tokens_per_image=self.tokens_per_image, tokens_per_file=self.tokens_per_file
64+
)
65+
66+
def to_dict(self) -> dict[str, Any]:
67+
"""
68+
Serialize the counter.
69+
70+
:returns: A dictionary representation of the counter.
71+
"""
72+
return default_to_dict(
73+
self,
74+
chars_per_token=self.chars_per_token,
75+
tokens_per_image=self.tokens_per_image,
76+
tokens_per_file=self.tokens_per_file,
77+
)
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
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, _rendered_tools
12+
from haystack.tools import ToolsType
13+
14+
with LazyImport("Run 'pip install tiktoken'") as tiktoken_imports:
15+
import tiktoken
16+
17+
18+
class TiktokenCounter(TokenCounter):
19+
"""
20+
Counts tokens locally with `tiktoken`, OpenAI's byte-pair encoder.
21+
22+
Counting is an estimate, and two limits are worth knowing before relying on it:
23+
- **It is text-only**, so images and files get the flat `tokens_per_image` / `tokens_per_file` estimate rather
24+
than a real count.
25+
- **It is OpenAI's encoder.** Other providers tokenize differently, so expect the count to drift on them.
26+
27+
## Usage Example:
28+
```python
29+
from haystack.dataclasses import ChatMessage
30+
from haystack.token_counters import TiktokenCounter
31+
32+
counter = TiktokenCounter(encoding="o200k_base")
33+
messages = [
34+
ChatMessage.from_user("Hello, how are you?"),
35+
ChatMessage.from_assistant("I'm good, thank you! How can I assist you today?")
36+
]
37+
token_count = counter.count(messages)
38+
print(f"Token count: {token_count}")
39+
```
40+
"""
41+
42+
def __init__(self, encoding: str = "o200k_base", tokens_per_image: int = 85, tokens_per_file: int = 1000) -> None:
43+
"""
44+
Initialize the counter.
45+
46+
:param encoding: The `tiktoken` encoding to count with. The default, `o200k_base`, is what current OpenAI
47+
models use.
48+
:param tokens_per_image: Tokens to charge per image, which the tokenizer cannot measure. The default is what
49+
OpenAI charges for a small image; raise it if you send large ones.
50+
:param tokens_per_file: Tokens to charge per file. A rough stand-in for a short document, since the real
51+
cost depends on the page count; raise it if you send long ones.
52+
:raises ImportError: If `tiktoken` is not installed.
53+
"""
54+
tiktoken_imports.check()
55+
self.encoding = encoding
56+
self.tokens_per_image = tokens_per_image
57+
self.tokens_per_file = tokens_per_file
58+
self._encoder: Any = None
59+
60+
def warm_up(self) -> None:
61+
"""Load the encoder, downloading its vocabulary if it is not already cached."""
62+
if self._encoder is not None:
63+
return
64+
self._encoder = tiktoken.get_encoding(self.encoding)
65+
66+
def count(self, messages: list[ChatMessage], tools: ToolsType | None = None) -> int:
67+
"""
68+
Return the estimated number of tokens used by the given messages.
69+
70+
:param messages: The messages to measure.
71+
:param tools: Tools whose schemas are sent alongside the messages, and so consume tokens too.
72+
:returns: The estimated token count, or `0` when there is nothing to measure.
73+
"""
74+
if not messages and not tools:
75+
return 0
76+
self.warm_up()
77+
text_tokens = len(self._encoder.encode(_rendered_conversation(messages) + _rendered_tools(tools)))
78+
return text_tokens + _non_text_tokens(
79+
messages, tokens_per_image=self.tokens_per_image, tokens_per_file=self.tokens_per_file
80+
)
81+
82+
def to_dict(self) -> dict[str, Any]:
83+
"""
84+
Serialize the counter.
85+
86+
:returns: A dictionary representation of the counter.
87+
"""
88+
return default_to_dict(
89+
self, encoding=self.encoding, tokens_per_image=self.tokens_per_image, tokens_per_file=self.tokens_per_file
90+
)
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: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
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
8+
from haystack.dataclasses import ChatMessage
9+
from haystack.tools import ToolsType
10+
11+
12+
class TokenCounter(Protocol):
13+
"""
14+
Estimates the number tokens used by a list of messages.
15+
16+
Implement `to_dict` so the counter's settings survive serialization. The default `from_dict` passes them straight
17+
back to the constructor, which is enough for plain values; override it when `to_dict` emitted something that has to
18+
be rebuilt first, such as a `Secret` or a nested component.
19+
"""
20+
21+
def count(self, messages: list[ChatMessage], tools: ToolsType | None = None) -> int:
22+
"""
23+
Return the estimated number of tokens in the given messages.
24+
25+
:param messages: The messages to measure.
26+
:param tools: Tools whose schemas are sent alongside the messages, and so consume tokens too. Pass them to have
27+
them counted; leave as None to measure the messages alone.
28+
:returns: The estimated token count.
29+
"""
30+
...
31+
32+
def to_dict(self) -> dict[str, Any]:
33+
"""Serialize the counter to a dictionary."""
34+
...
35+
36+
@classmethod
37+
def from_dict(cls, data: dict[str, Any]) -> "TokenCounter":
38+
"""Deserialize the counter from a dictionary."""
39+
return default_from_dict(cls, data)

haystack/token_counters/utils.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
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+
from haystack.tools import ToolsType, flatten_tools_or_toolsets
10+
11+
12+
def _non_text_placeholder(content: ChatMessageContentT) -> str:
13+
"""A short stand-in, such as `<image>`, for message content that has no text form."""
14+
if isinstance(content, ImageContent):
15+
return "<image>"
16+
if isinstance(content, FileContent):
17+
return f"<file: {content.filename or 'unnamed'}>"
18+
return f"<{type(content).__name__}>"
19+
20+
21+
def _tool_result_text(result: ToolCallResultContentT) -> str:
22+
"""A tool result as a single string, with placeholders standing in for any non-text parts."""
23+
if isinstance(result, str):
24+
return result
25+
return "".join(block.text if isinstance(block, TextContent) else _non_text_placeholder(block) for block in result)
26+
27+
28+
def _render_message(message: ChatMessage) -> str:
29+
"""
30+
One message as one or more lines of plain text.
31+
32+
Reasoning content is deliberately left out: providers discard it between turns, so it is not part of the context
33+
being measured.
34+
"""
35+
role = message.role.value
36+
# A tool-result msg only carries tool_call_results, so it is rendered on its own and labelled with the tool that
37+
# produced it.
38+
if results := message.tool_call_results:
39+
return "\n".join(
40+
f"[tool:{result.origin.tool_name}{' (error)' if result.error else ''}] {_tool_result_text(result.result)}"
41+
for result in results
42+
)
43+
44+
lines: list[str] = []
45+
if texts := message.texts:
46+
lines.append(f"[{role}] " + "\n".join(texts))
47+
for call in message.tool_calls:
48+
arguments = json.dumps(call.arguments, default=str, sort_keys=True)
49+
lines.append(f"[{role} -> tool_call] {call.tool_name}({arguments})")
50+
# Images and files cost tokens too, so they need a stand-in rather than being skipped.
51+
non_text: list[ChatMessageContentT] = [*message.images, *message.files]
52+
for content in non_text:
53+
lines.append(f"[{role}] {_non_text_placeholder(content)}")
54+
return "\n".join(lines) if lines else f"[{role}] <no content>"
55+
56+
57+
def _rendered_conversation(messages: list[ChatMessage]) -> str:
58+
"""The whole conversation as one plain-text block, which is what a counter measures."""
59+
return "\n".join(_render_message(message) for message in messages)
60+
61+
62+
def _rendered_tools(tools: ToolsType | None) -> str:
63+
"""
64+
Tool schemas as one JSON block, standing in for what a provider sends alongside the messages.
65+
66+
:param tools: The tools to render, or None.
67+
:returns: The rendered schemas, or an empty string when there are none.
68+
"""
69+
if not tools:
70+
return ""
71+
return json.dumps([tool.tool_spec for tool in flatten_tools_or_toolsets(tools)], default=str, sort_keys=True)
72+
73+
74+
def _non_text_tokens(messages: list[ChatMessage], *, tokens_per_image: int, tokens_per_file: int) -> int:
75+
"""
76+
A flat estimate for the images and files in a conversation, which have no text to measure.
77+
78+
:param messages: The conversation to measure.
79+
:param tokens_per_image: Tokens to charge per image.
80+
:param tokens_per_file: Tokens to charge per file.
81+
:returns: The estimated token count for the non-text content.
82+
"""
83+
images = 0
84+
files = 0
85+
for message in messages:
86+
images += len(message.images)
87+
files += len(message.files)
88+
# Images and files returned by a tool are nested inside the tool result
89+
for result in message.tool_call_results:
90+
if isinstance(result.result, str):
91+
continue
92+
images += sum(isinstance(block, ImageContent) for block in result.result)
93+
files += sum(isinstance(block, FileContent) for block in result.result)
94+
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: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
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+
Tool schemas are sent alongside the messages and consume tokens too, so ``count`` takes an optional ``tools``
35+
argument to have them included:
36+
37+
.. code-block:: python
38+
39+
counter.count(messages, tools=[my_tool])
40+
41+
Implement ``TokenCounter`` to count differently - for instance against a provider's own token-counting endpoint,
42+
which is the only way to have images counted exactly.

0 commit comments

Comments
 (0)