Coverage for haystack/token_counters/utils.py: 100%
49 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 13:53 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 13:53 +0000
1# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
2#
3# SPDX-License-Identifier: Apache-2.0
5import json
6from collections.abc import Callable
8from haystack.dataclasses import ChatMessage, FileContent, ImageContent, TextContent
9from haystack.dataclasses.chat_message import ChatMessageContentT, ToolCallResultContentT
10from haystack.tools import ToolsType, flatten_tools_or_toolsets
12# Builds the stand-in for message content that has no text form, such as an image.
13_PlaceholderFn = Callable[[ChatMessageContentT], str]
16def _non_text_placeholder(content: ChatMessageContentT) -> str:
17 """A short stand-in, such as `<image>`, for message content that has no text form."""
18 if isinstance(content, ImageContent):
19 return "<image>"
20 if isinstance(content, FileContent):
21 return f"<file: {content.filename or 'unnamed'}>"
22 return f"<{type(content).__name__}>"
25def _tool_result_text(result: ToolCallResultContentT, placeholder: _PlaceholderFn = _non_text_placeholder) -> str:
26 """A tool result as a single string, with placeholders standing in for any non-text parts."""
27 if isinstance(result, str):
28 return result
29 return "".join(block.text if isinstance(block, TextContent) else placeholder(block) for block in result)
32def _render_message(
33 message: ChatMessage, placeholder: _PlaceholderFn = _non_text_placeholder, include_tool_call_ids: bool = False
34) -> str:
35 """
36 One message as one or more lines of plain text.
38 Reasoning content is deliberately left out: providers discard it between turns, so it is not part of the context
39 being measured.
41 :param message: The message to render.
42 :param placeholder: Builds the stand-in for content that has no text form. The default is short and stable, which
43 is what a counter needs because the stand-in's own length is what gets measured. A caller rendering for a model
44 to read can pass one that describes the content instead.
45 :param include_tool_call_ids: Whether to include tool call IDs in the rendered message.
46 :returns: The rendered message.
47 """
48 role = message.role.value
49 # A tool-result msg only carries tool_call_results, so it is rendered on its own and labelled with the tool that
50 # produced it.
51 if results := message.tool_call_results:
52 return "\n".join(
53 f"[tool:{result.origin.tool_name}"
54 f"{' id=' + result.origin.id if include_tool_call_ids and result.origin.id else ''}"
55 f"{' (error)' if result.error else ''}] "
56 f"{_tool_result_text(result.result, placeholder=placeholder)}"
57 for result in results
58 )
60 lines: list[str] = []
61 if texts := message.texts:
62 lines.append(f"[{role}] " + "\n".join(texts))
63 for call in message.tool_calls:
64 arguments = json.dumps(call.arguments, default=str, sort_keys=True)
65 call_id = f" id={call.id}" if include_tool_call_ids and call.id else ""
66 lines.append(f"[{role} -> tool_call{call_id}] {call.tool_name}({arguments})")
67 # Images and files cost tokens too, so they need a stand-in rather than being skipped.
68 non_text: list[ChatMessageContentT] = [*message.images, *message.files]
69 for content in non_text:
70 lines.append(f"[{role}] {placeholder(content)}")
71 return "\n".join(lines) if lines else f"[{role}] <no content>"
74def _rendered_conversation(
75 messages: list[ChatMessage],
76 *,
77 placeholder: _PlaceholderFn = _non_text_placeholder,
78 include_tool_call_ids: bool = False,
79) -> str:
80 """The whole conversation as one plain-text block, which is what a counter measures."""
81 return "\n".join(
82 _render_message(message, placeholder=placeholder, include_tool_call_ids=include_tool_call_ids)
83 for message in messages
84 )
87def _rendered_tools(tools: ToolsType | None) -> str:
88 """
89 Tool schemas as one JSON block, standing in for what a provider sends alongside the messages.
91 :param tools: The tools to render, or None.
92 :returns: The rendered schemas, or an empty string when there are none.
93 """
94 if not tools:
95 return ""
96 return json.dumps([tool.tool_spec for tool in flatten_tools_or_toolsets(tools)], default=str, sort_keys=True)
99def _non_text_tokens(messages: list[ChatMessage], *, tokens_per_image: int, tokens_per_file: int) -> int:
100 """
101 A flat estimate for the images and files in a conversation, which have no text to measure.
103 :param messages: The conversation to measure.
104 :param tokens_per_image: Tokens to charge per image.
105 :param tokens_per_file: Tokens to charge per file.
106 :returns: The estimated token count for the non-text content.
107 """
108 images = 0
109 files = 0
110 for message in messages:
111 images += len(message.images)
112 files += len(message.files)
113 # Images and files returned by a tool are nested inside the tool result
114 for result in message.tool_call_results:
115 if isinstance(result.result, str):
116 continue
117 images += sum(isinstance(block, ImageContent) for block in result.result)
118 files += sum(isinstance(block, FileContent) for block in result.result)
119 return images * tokens_per_image + files * tokens_per_file