Skip to content

Commit ec7a076

Browse files
committed
PR comments
1 parent ad1ccbb commit ec7a076

8 files changed

Lines changed: 148 additions & 20 deletions

File tree

haystack/token_counters/approximate_counter.py

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
from haystack.core.serialization import default_to_dict
88
from haystack.dataclasses import ChatMessage
99
from haystack.token_counters.types import TokenCounter
10-
from haystack.token_counters.utils import _non_text_tokens, _rendered_conversation
10+
from haystack.token_counters.utils import _non_text_tokens, _rendered_conversation, _rendered_tools
11+
from haystack.tools import ToolsType
1112

1213

1314
class ApproximateTokenCounter(TokenCounter):
@@ -46,17 +47,21 @@ def __init__(self, chars_per_token: float = 4.0, tokens_per_image: int = 85, tok
4647
self.tokens_per_image = tokens_per_image
4748
self.tokens_per_file = tokens_per_file
4849

49-
def count(self, messages: list[ChatMessage]) -> int:
50+
def count(self, messages: list[ChatMessage], tools: ToolsType | None = None) -> int:
5051
"""
5152
Return the estimated number of tokens the given messages occupy.
5253
5354
:param messages: The messages to measure.
54-
:returns: The estimated token count, or `0` for an empty list.
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.
5557
"""
56-
if not messages:
58+
if not messages and not tools:
5759
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+
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, tokens_per_image=self.tokens_per_image, tokens_per_file=self.tokens_per_file
64+
)
6065

6166
def to_dict(self) -> dict[str, Any]:
6267
"""

haystack/token_counters/tiktoken_counter.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
from haystack.dataclasses import ChatMessage
99
from haystack.lazy_imports import LazyImport
1010
from haystack.token_counters.types import TokenCounter
11-
from haystack.token_counters.utils import _non_text_tokens, _rendered_conversation
11+
from haystack.token_counters.utils import _non_text_tokens, _rendered_conversation, _rendered_tools
12+
from haystack.tools import ToolsType
1213

1314
with LazyImport("Run 'pip install tiktoken'") as tiktoken_imports:
1415
import tiktoken
@@ -62,18 +63,21 @@ def warm_up(self) -> None:
6263
return
6364
self._encoder = tiktoken.get_encoding(self.encoding)
6465

65-
def count(self, messages: list[ChatMessage]) -> int:
66+
def count(self, messages: list[ChatMessage], tools: ToolsType | None = None) -> int:
6667
"""
6768
Return the estimated number of tokens used by the given messages.
6869
6970
:param messages: The messages to measure.
70-
:returns: The estimated token count, or `0` for an empty list.
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.
7173
"""
72-
if not messages:
74+
if not messages and not tools:
7375
return 0
7476
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+
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+
)
7781

7882
def to_dict(self) -> dict[str, Any]:
7983
"""

haystack/token_counters/types/protocol.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,30 +4,34 @@
44

55
from typing import Any, Protocol
66

7-
from haystack.core.serialization import default_from_dict, default_to_dict
7+
from haystack.core.serialization import default_from_dict
88
from haystack.dataclasses import ChatMessage
9+
from haystack.tools import ToolsType
910

1011

1112
class TokenCounter(Protocol):
1213
"""
1314
Estimates the number tokens used by a list of messages.
1415
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.
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.
1719
"""
1820

19-
def count(self, messages: list[ChatMessage]) -> int:
21+
def count(self, messages: list[ChatMessage], tools: ToolsType | None = None) -> int:
2022
"""
2123
Return the estimated number of tokens in the given messages.
2224
2325
: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.
2428
:returns: The estimated token count.
2529
"""
2630
...
2731

2832
def to_dict(self) -> dict[str, Any]:
2933
"""Serialize the counter to a dictionary."""
30-
return default_to_dict(self)
34+
...
3135

3236
@classmethod
3337
def from_dict(cls, data: dict[str, Any]) -> "TokenCounter":

haystack/token_counters/utils.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from haystack.dataclasses import ChatMessage, FileContent, ImageContent, TextContent
88
from haystack.dataclasses.chat_message import ChatMessageContentT, ToolCallResultContentT
9+
from haystack.tools import ToolsType, flatten_tools_or_toolsets
910

1011

1112
def _non_text_placeholder(content: ChatMessageContentT) -> str:
@@ -58,7 +59,19 @@ def _rendered_conversation(messages: list[ChatMessage]) -> str:
5859
return "\n".join(_render_message(message) for message in messages)
5960

6061

61-
def _non_text_tokens(messages: list[ChatMessage], tokens_per_image: int, tokens_per_file: int) -> int:
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:
6275
"""
6376
A flat estimate for the images and files in a conversation, which have no text to measure.
6477

releasenotes/notes/add-token-counters-f2685e989bff106e.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,5 +31,12 @@ features:
3131
returned inside its result as well as those a message carries directly. Raise those values if you send large images
3232
or long documents.
3333
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+
3441
Implement ``TokenCounter`` to count differently - for instance against a provider's own token-counting endpoint,
3542
which is the only way to have images counted exactly.

test/token_counters/test_approximate_counter.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,14 @@
22
#
33
# SPDX-License-Identifier: Apache-2.0
44

5+
from typing import Annotated
6+
57
import pytest
68

79
from haystack.dataclasses import ChatMessage, FileContent, ImageContent, TextContent, ToolCall
810
from haystack.token_counters import ApproximateTokenCounter
911
from haystack.token_counters.utils import _rendered_conversation
12+
from haystack.tools import tool
1013

1114
IMAGE = ImageContent(base64_image="Zm9v", mime_type="image/png")
1215
FILE = FileContent(base64_data="Zm9v", mime_type="application/pdf", filename="report.pdf")
@@ -79,3 +82,25 @@ def test_serde_round_trip(self):
7982
restored = ApproximateTokenCounter.from_dict(data)
8083
assert restored.chars_per_token == 3.5
8184
assert restored.tokens_per_file == 3000
85+
86+
87+
@tool
88+
def search(query: Annotated[str, "the search query"]) -> str:
89+
"""Search the web for a query and return the top results."""
90+
return "result"
91+
92+
93+
class TestApproximateTokenCounterTools:
94+
def test_tool_schemas_add_to_the_count(self):
95+
# A provider is sent the schemas alongside the messages, so they consume tokens too.
96+
counter = ApproximateTokenCounter()
97+
messages = [ChatMessage.from_user("hi")]
98+
99+
assert counter.count(messages, tools=[search]) > counter.count(messages)
100+
101+
def test_tools_can_be_counted_without_messages(self):
102+
assert ApproximateTokenCounter().count([], tools=[search]) > 0
103+
104+
def test_nothing_to_measure_is_zero(self):
105+
assert ApproximateTokenCounter().count([]) == 0
106+
assert ApproximateTokenCounter().count([], tools=None) == 0

test/token_counters/test_tiktoken_counter.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,14 @@
22
#
33
# SPDX-License-Identifier: Apache-2.0
44

5+
from typing import Annotated
6+
57
import pytest
68

79
from haystack.dataclasses import ChatMessage, FileContent, ImageContent, ToolCall
810
from haystack.token_counters import TiktokenCounter
911
from haystack.token_counters import tiktoken_counter as tiktoken_counter_module
12+
from haystack.tools import tool
1013

1114
IMAGE = ImageContent(base64_image="Zm9v", mime_type="image/png")
1215
FILE = FileContent(base64_data="Zm9v", mime_type="application/pdf", filename="report.pdf")
@@ -134,3 +137,25 @@ def test_an_image_is_charged_at_the_flat_rate(self):
134137
counter = TiktokenCounter(tokens_per_image=85)
135138

136139
assert counter.count([ChatMessage.from_user(content_parts=[IMAGE])]) > 85
140+
141+
142+
@tool
143+
def search(query: Annotated[str, "the search query"]) -> str:
144+
"""Search the web for a query and return the top results."""
145+
return "result"
146+
147+
148+
class TestTiktokenCounterTools:
149+
def test_tool_schemas_add_to_the_count(self):
150+
# A provider is sent the schemas alongside the messages, so they consume tokens too.
151+
counter = TiktokenCounter()
152+
messages = [ChatMessage.from_user("hi")]
153+
154+
assert counter.count(messages, tools=[search]) > counter.count(messages)
155+
156+
def test_tools_can_be_counted_without_messages(self):
157+
assert TiktokenCounter().count([], tools=[search]) > 0
158+
159+
def test_nothing_to_measure_is_zero(self):
160+
assert TiktokenCounter().count([]) == 0
161+
assert TiktokenCounter().count([], tools=None) == 0

test/token_counters/test_utils.py

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,19 @@
22
#
33
# SPDX-License-Identifier: Apache-2.0
44

5+
from typing import Annotated
6+
57
import pytest
68

7-
from haystack.dataclasses import ChatMessage, FileContent, ImageContent, TextContent, ToolCall
8-
from haystack.token_counters.utils import _render_message, _rendered_conversation, _tool_result_text
9+
from haystack.dataclasses import ChatMessage, FileContent, ImageContent, ReasoningContent, TextContent, ToolCall
10+
from haystack.token_counters.utils import (
11+
_non_text_placeholder,
12+
_render_message,
13+
_rendered_conversation,
14+
_rendered_tools,
15+
_tool_result_text,
16+
)
17+
from haystack.tools import tool
918

1019
IMAGE = ImageContent(base64_image="Zm9v", mime_type="image/png")
1120
FILE = FileContent(base64_data="Zm9v", mime_type="application/pdf", filename="report.pdf")
@@ -65,3 +74,39 @@ def test_joins_messages_with_newlines(self):
6574

6675
def test_empty_conversation(self):
6776
assert _rendered_conversation([]) == ""
77+
78+
79+
@tool
80+
def search(query: Annotated[str, "the search query"]) -> str:
81+
"""Search the web for a query."""
82+
return "result"
83+
84+
85+
class TestNonTextPlaceholder:
86+
@pytest.mark.parametrize(
87+
("content", "expected"),
88+
[
89+
pytest.param(IMAGE, "<image>", id="image"),
90+
pytest.param(FILE, "<file: report.pdf>", id="file-with-name"),
91+
pytest.param(
92+
FileContent(base64_data="Zm9v", mime_type="application/pdf"), "<file: unnamed>", id="file-unnamed"
93+
),
94+
# Anything else falls back to naming its type, so an unexpected block still shows up in the text.
95+
pytest.param(ReasoningContent(reasoning_text="thinking"), "<ReasoningContent>", id="unknown-type"),
96+
],
97+
)
98+
def test_placeholder(self, content, expected):
99+
assert _non_text_placeholder(content) == expected
100+
101+
102+
class TestRenderedTools:
103+
def test_no_tools_renders_nothing(self):
104+
assert _rendered_tools(None) == ""
105+
assert _rendered_tools([]) == ""
106+
107+
def test_renders_the_schema_a_provider_would_be_sent(self):
108+
rendered = _rendered_tools([search])
109+
110+
assert "search" in rendered
111+
assert "Search the web for a query." in rendered
112+
assert "the search query" in rendered

0 commit comments

Comments
 (0)