Skip to content

Commit c9cfddc

Browse files
authored
Merge pull request #7 from pamelafox/functions-support
Adding support for counting tokens of functions
2 parents 15fd55d + 1acce78 commit c9cfddc

File tree

12 files changed

+860
-60
lines changed

12 files changed

+860
-60
lines changed

.vscode/launch.json

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{
2+
// Use IntelliSense to learn about possible attributes.
3+
// Hover to view descriptions of existing attributes.
4+
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
5+
"version": "0.2.0",
6+
"configurations": [
7+
{
8+
"name": "Python: Debug Tests",
9+
"type": "debugpy",
10+
"request": "launch",
11+
"program": "${file}",
12+
"purpose": ["debug-test"],
13+
"console": "integratedTerminal",
14+
"justMyCode": false
15+
}
16+
]
17+
}

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22

33
All notable changes to this project will be documented in this file.
44

5+
## [0.1.0] - May 2, 2024
6+
7+
- Add `count_tokens_for_system_and_tools` to count tokens for system message and tools. You should count the tokens for both together, since the token count for tools varies based off whether a system message is provided.
8+
- Updated `build_messages` to allow for `tools` and `tool_choice` to be passed in.
9+
510
## [0.0.6] - April 24, 2024
611

712
- Add keyword argument `fallback_to_default` to `build_messages` function to allow for defaulting to the CL100k token encoder and minimum GPT token limit if the model is not found.

README.md

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,14 @@ Arguments:
3131

3232
* `model` (`str`): The model name to use for token calculation, like gpt-3.5-turbo.
3333
* `system_prompt` (`str`): The initial system prompt message.
34-
* `new_user_message` (`str | List[openai.types.chat.ChatCompletionContentPartParam]`): The new user message to append.
35-
* `past_messages` (`list[dict]`): The list of past messages in the conversation.
36-
* `few_shots` (`list[dict]`): A few-shot list of messages to insert after the system prompt.
37-
* `max_tokens` (`int`): The maximum number of tokens allowed for the conversation.
38-
* `fallback_to_default` (`bool`): Whether to fallback to default model/token limits if model is not found. Defaults to `False`.
34+
* `tools` (`List[openai.types.chat.ChatCompletionToolParam]`): (Optional) The tools that will be used in the conversation. These won't be part of the final returned messages, but they will be used to calculate the token count.
35+
* `tool_choice` (`str | dict`): (Optional) The tool choice that will be used in the conversation. This won't be part of the final returned messages, but it will be used to calculate the token count.
36+
* `new_user_content` (`str | List[openai.types.chat.ChatCompletionContentPartParam]`): (Optional) The content of new user message to append.
37+
* `past_messages` (`list[dict]`): (Optional) The list of past messages in the conversation.
38+
* `few_shots` (`list[dict]`): (Optional) A few-shot list of messages to insert after the system prompt.
39+
* `max_tokens` (`int`): (Optional) The maximum number of tokens allowed for the conversation.
40+
* `fallback_to_default` (`bool`): (Optional) Whether to fallback to default model/token limits if model is not found. Defaults to `False`.
41+
3942

4043
Returns:
4144

@@ -49,7 +52,7 @@ from openai_messages_token_helper import build_messages
4952
messages = build_messages(
5053
model="gpt-35-turbo",
5154
system_prompt="You are a bot.",
52-
new_user_message="That wasn't a good poem.",
55+
new_user_content="That wasn't a good poem.",
5356
past_messages=[
5457
{
5558
"role": "user",

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
[project]
22
name = "openai-messages-token-helper"
33
description = "A helper library for estimating tokens used by messages sent through OpenAI Chat Completions API."
4-
version = "0.0.6"
4+
version = "0.1.0"
55
authors = [{name = "Pamela Fox"}]
66
requires-python = ">=3.9"
77
readme = "README.md"
Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
from .images_helper import count_tokens_for_image
22
from .message_builder import build_messages
3-
from .model_helper import count_tokens_for_message, get_token_limit
3+
from .model_helper import count_tokens_for_message, count_tokens_for_system_and_tools, get_token_limit
44

5-
__all__ = ["build_messages", "count_tokens_for_message", "count_tokens_for_image", "get_token_limit"]
5+
__all__ = [
6+
"build_messages",
7+
"count_tokens_for_message",
8+
"count_tokens_for_image",
9+
"get_token_limit",
10+
"count_tokens_for_system_and_tools",
11+
]
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# Based on https://github.com/forestwanglin/openai-java/blob/main/jtokkit/src/main/java/xyz/felh/openai/jtokkit/utils/TikTokenUtils.java
2+
3+
4+
def format_function_definitions(tools):
5+
lines = []
6+
lines.append("namespace functions {")
7+
lines.append("")
8+
for tool in tools:
9+
function = tool.get("function")
10+
if function_description := function.get("description"):
11+
lines.append(f"// {function_description}")
12+
function_name = function.get("name")
13+
parameters = function.get("parameters", {})
14+
properties = parameters.get("properties")
15+
if properties and properties.keys():
16+
lines.append(f"type {function_name} = (_: {{")
17+
lines.append(format_object_parameters(parameters, 0))
18+
lines.append("}) => any;")
19+
else:
20+
lines.append(f"type {function_name} = () => any;")
21+
lines.append("")
22+
lines.append("} // namespace functions")
23+
return "\n".join(lines)
24+
25+
26+
def format_object_parameters(parameters, indent):
27+
properties = parameters.get("properties")
28+
if not properties:
29+
return ""
30+
required_params = parameters.get("required", [])
31+
lines = []
32+
for key, props in properties.items():
33+
description = props.get("description")
34+
if description:
35+
lines.append(f"// {description}")
36+
question = "?"
37+
if required_params and key in required_params:
38+
question = ""
39+
lines.append(f"{key}{question}: {format_type(props, indent)},")
40+
return "\n".join([" " * max(0, indent) + line for line in lines])
41+
42+
43+
def format_type(props, indent):
44+
type = props.get("type")
45+
if type == "string":
46+
if "enum" in props:
47+
return " | ".join([f'"{item}"' for item in props["enum"]])
48+
return "string"
49+
elif type == "array":
50+
# items is required, OpenAI throws an error if it's missing
51+
return f"{format_type(props['items'], indent)}[]"
52+
elif type == "object":
53+
return f"{{\n{format_object_parameters(props, indent + 2)}\n}}"
54+
elif type in ["integer", "number"]:
55+
if "enum" in props:
56+
return " | ".join([f'"{item}"' for item in props["enum"]])
57+
return "number"
58+
elif type == "boolean":
59+
return "boolean"
60+
elif type == "null":
61+
return "null"
62+
else:
63+
# This is a guess, as an empty string doesn't yield the expected token count
64+
return "any"

src/openai_messages_token_helper/message_builder.py

Lines changed: 32 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,20 @@
1010
ChatCompletionUserMessageParam,
1111
)
1212

13-
from .model_helper import count_tokens_for_message, get_token_limit
13+
from .model_helper import count_tokens_for_message, count_tokens_for_system_and_tools, get_token_limit
1414

1515

16-
class MessageBuilder:
16+
def normalize_content(content: Union[str, list[ChatCompletionContentPartParam]]):
17+
if isinstance(content, str):
18+
return unicodedata.normalize("NFC", content)
19+
elif isinstance(content, list):
20+
for part in content:
21+
if "image_url" not in part:
22+
part["text"] = unicodedata.normalize("NFC", part["text"])
23+
return content
24+
25+
26+
class _MessageBuilder:
1727
"""
1828
A class for building and managing messages in a chat conversation.
1929
Attributes:
@@ -25,11 +35,10 @@ class MessageBuilder:
2535
insert_message(self, role: str, content: str, index: int = 1): Inserts a new message to the conversation.
2636
"""
2737

28-
def __init__(self, system_content: str, chatgpt_model: str):
38+
def __init__(self, system_content: str):
2939
self.messages: list[ChatCompletionMessageParam] = [
30-
ChatCompletionSystemMessageParam(role="system", content=unicodedata.normalize("NFC", system_content))
40+
ChatCompletionSystemMessageParam(role="system", content=normalize_content(system_content))
3141
]
32-
self.model = chatgpt_model
3342

3443
def insert_message(self, role: str, content: Union[str, list[ChatCompletionContentPartParam]], index: int = 1):
3544
"""
@@ -42,29 +51,21 @@ def insert_message(self, role: str, content: Union[str, list[ChatCompletionConte
4251
"""
4352
message: ChatCompletionMessageParam
4453
if role == "user":
45-
message = ChatCompletionUserMessageParam(role="user", content=self.normalize_content(content))
54+
message = ChatCompletionUserMessageParam(role="user", content=normalize_content(content))
4655
elif role == "assistant" and isinstance(content, str):
47-
message = ChatCompletionAssistantMessageParam(
48-
role="assistant", content=unicodedata.normalize("NFC", content)
49-
)
56+
message = ChatCompletionAssistantMessageParam(role="assistant", content=normalize_content(content))
5057
else:
5158
raise ValueError(f"Invalid role: {role}")
5259
self.messages.insert(index, message)
5360

54-
def normalize_content(self, content: Union[str, list[ChatCompletionContentPartParam]]):
55-
if isinstance(content, str):
56-
return unicodedata.normalize("NFC", content)
57-
elif isinstance(content, list):
58-
for part in content:
59-
if "image_url" not in part:
60-
part["text"] = unicodedata.normalize("NFC", part["text"])
61-
return content
62-
6361

6462
def build_messages(
6563
model: str,
6664
system_prompt: str,
67-
new_user_message: Union[str, list[ChatCompletionContentPartParam], None] = None, # list is for GPT4v usage
65+
*,
66+
tools: Optional[list[dict[str, dict]]] = None,
67+
tool_choice: Optional[Union[str, dict]] = None,
68+
new_user_content: Union[str, list[ChatCompletionContentPartParam], None] = None, # list is for GPT4v usage
6869
past_messages: list[dict[str, str]] = [], # *not* including system prompt
6970
few_shots=[], # will always be inserted after system prompt
7071
max_tokens: Optional[int] = None,
@@ -77,26 +78,32 @@ def build_messages(
7778
Args:
7879
model (str): The model name to use for token calculation, like gpt-3.5-turbo.
7980
system_prompt (str): The initial system prompt message.
80-
new_user_message (str | List[ChatCompletionContentPartParam]): The new user message to append.
81+
tools (list[dict]): A list of tools to include in the conversation.
82+
tool_choice (str | dict): The tool to use in the conversation.
83+
new_user_content (str | List[ChatCompletionContentPartParam]): Content of new user message to append.
8184
past_messages (list[dict]): The list of past messages in the conversation.
8285
few_shots (list[dict]): A few-shot list of messages to insert after the system prompt.
8386
max_tokens (int): The maximum number of tokens allowed for the conversation.
8487
fallback_to_default (bool): Whether to fallback to default model if the model is not found.
8588
"""
86-
message_builder = MessageBuilder(system_prompt, model)
8789
if max_tokens is None:
8890
max_tokens = get_token_limit(model, default_to_minimum=fallback_to_default)
8991

92+
# Start with the required messages: system prompt, few-shots, and new user message
93+
message_builder = _MessageBuilder(system_prompt)
94+
9095
for shot in reversed(few_shots):
9196
message_builder.insert_message(shot.get("role"), shot.get("content"))
9297

9398
append_index = len(few_shots) + 1
9499

95-
if new_user_message:
96-
message_builder.insert_message("user", new_user_message, index=append_index)
100+
if new_user_content:
101+
message_builder.insert_message("user", new_user_content, index=append_index)
97102

98-
total_token_count = 0
99-
for existing_message in message_builder.messages:
103+
total_token_count = count_tokens_for_system_and_tools(
104+
model, message_builder.messages[0], tools, tool_choice, default_to_cl100k=fallback_to_default
105+
)
106+
for existing_message in message_builder.messages[1:]:
100107
total_token_count += count_tokens_for_message(model, existing_message, default_to_cl100k=fallback_to_default)
101108

102109
newest_to_oldest = list(reversed(past_messages))

src/openai_messages_token_helper/model_helper.py

Lines changed: 72 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import tiktoken
77

8+
from .function_format import format_function_definitions
89
from .images_helper import count_tokens_for_image
910

1011
MODELS_2_TOKEN_LIMITS = {
@@ -42,22 +43,14 @@ def get_token_limit(model: str, default_to_minimum=False) -> int:
4243
return MODELS_2_TOKEN_LIMITS[model]
4344

4445

45-
def count_tokens_for_message(model: str, message: Mapping[str, object], default_to_cl100k=False) -> int:
46+
def encoding_for_model(model: str, default_to_cl100k=False) -> tiktoken.Encoding:
4647
"""
47-
Calculate the number of tokens required to encode a message. Based off cookbook:
48-
https://github.com/openai/openai-cookbook/blob/main/examples/How_to_count_tokens_with_tiktoken.ipynb
49-
48+
Get the encoding for a given GPT model name (OpenAI.com or Azure OpenAI supported).
5049
Args:
51-
model (str): The name of the model to use for encoding.
52-
message (Mapping): The message to encode, in a dictionary-like object.
50+
model (str): The name of the model to get the encoding for.
5351
default_to_cl100k (bool): Whether to default to the CL100k encoding if the model is not found.
5452
Returns:
55-
int: The total number of tokens required to encode the message.
56-
57-
>> model = 'gpt-3.5-turbo'
58-
>> message = {'role': 'user', 'content': 'Hello, how are you?'}
59-
>> count_tokens_for_message(model, message)
60-
13
53+
tiktoken.Encoding: The encoding for the model.
6154
"""
6255
if (
6356
model == ""
@@ -67,14 +60,34 @@ def count_tokens_for_message(model: str, message: Mapping[str, object], default_
6760
raise ValueError("Expected valid OpenAI GPT model name")
6861
model = AOAI_2_OAI.get(model, model)
6962
try:
70-
encoding = tiktoken.encoding_for_model(model)
63+
return tiktoken.encoding_for_model(model)
7164
except KeyError:
7265
if default_to_cl100k:
7366
logger.warning("Model %s not found, defaulting to CL100k encoding", model)
74-
encoding = tiktoken.get_encoding("cl100k_base")
67+
return tiktoken.get_encoding("cl100k_base")
7568
else:
7669
raise
7770

71+
72+
def count_tokens_for_message(model: str, message: Mapping[str, object], default_to_cl100k=False) -> int:
73+
"""
74+
Calculate the number of tokens required to encode a message. Based off cookbook:
75+
https://github.com/openai/openai-cookbook/blob/main/examples/How_to_count_tokens_with_tiktoken.ipynb
76+
77+
Args:
78+
model (str): The name of the model to use for encoding.
79+
message (Mapping): The message to encode, in a dictionary-like object.
80+
default_to_cl100k (bool): Whether to default to the CL100k encoding if the model is not found.
81+
Returns:
82+
int: The total number of tokens required to encode the message.
83+
84+
>> model = 'gpt-3.5-turbo'
85+
>> message = {'role': 'user', 'content': 'Hello, how are you?'}
86+
>> count_tokens_for_message(model, message)
87+
13
88+
"""
89+
encoding = encoding_for_model(model, default_to_cl100k)
90+
7891
# Assumes we're using a recent model
7992
tokens_per_message = 3
8093

@@ -96,3 +109,48 @@ def count_tokens_for_message(model: str, message: Mapping[str, object], default_
96109
num_tokens += 1
97110
num_tokens += 3 # every reply is primed with <|start|>assistant<|message|>
98111
return num_tokens
112+
113+
114+
def count_tokens_for_system_and_tools(
115+
model: str,
116+
system_message: dict | None = None,
117+
tools: list[dict[str, dict]] | None = None,
118+
tool_choice: str | dict | None = None,
119+
default_to_cl100k: bool = False,
120+
) -> int:
121+
"""
122+
Calculate the number of tokens required to encode a system message and tools.
123+
Both must be calculated together because the count is lower if both are present.
124+
Based on https://github.com/forestwanglin/openai-java/blob/main/jtokkit/src/main/java/xyz/felh/openai/jtokkit/utils/TikTokenUtils.java
125+
126+
Args:
127+
model (str): The name of the model to use for encoding.
128+
tools (list[dict[str, dict]]): The tools to encode.
129+
tool_choice (str | dict): The tool choice to encode.
130+
system_message (dict): The system message to encode.
131+
default_to_cl100k (bool): Whether to default to the CL100k encoding if the model is not found.
132+
Returns:
133+
int: The total number of tokens required to encode the system message and tools.
134+
"""
135+
encoding = encoding_for_model(model, default_to_cl100k)
136+
137+
tokens = 0
138+
if system_message:
139+
tokens += count_tokens_for_message(model, system_message, default_to_cl100k)
140+
if tools:
141+
encoding = tiktoken.encoding_for_model(model)
142+
print(format_function_definitions(tools))
143+
tokens += len(encoding.encode(format_function_definitions(tools)))
144+
tokens += 9 # Additional tokens for function definition of tools
145+
# If there's a system message and tools are present, subtract four tokens
146+
if tools and system_message:
147+
tokens -= 4
148+
# If tool_choice is 'none', add one token.
149+
# If it's an object, add 4 + the number of tokens in the function name.
150+
# If it's undefined or 'auto', don't add anything.
151+
if tool_choice == "none":
152+
tokens += 1
153+
elif isinstance(tool_choice, dict):
154+
tokens += 7
155+
tokens += len(encoding.encode(tool_choice["function"]["name"]))
156+
return tokens

0 commit comments

Comments
 (0)