Skip to content

Commit 0b301cb

Browse files
authored
Merge pull request #2 from pamelafox/tokencounting
Count tokens better
2 parents 6385b73 + b3a235c commit 0b301cb

File tree

9 files changed

+185
-78
lines changed

9 files changed

+185
-78
lines changed

.pre-commit-config.yaml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
repos:
22
- repo: https://github.com/pre-commit/pre-commit-hooks
3-
rev: v4.4.0
3+
rev: v4.6.0
44
hooks:
55
- id: check-yaml
66
- id: end-of-file-fixer
77
- id: trailing-whitespace
88
- repo: https://github.com/astral-sh/ruff-pre-commit
9-
rev: v0.0.280
9+
rev: v0.4.1
1010
hooks:
1111
- id: ruff
1212
- repo: https://github.com/psf/black
13-
rev: 23.1.0
13+
rev: 24.4.0
1414
hooks:
1515
- id: black

.vscode/settings.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"python.testing.pytestArgs": [
3+
"tests"
4+
],
5+
"python.testing.unittestEnabled": false,
6+
"python.testing.pytestEnabled": true
7+
}

pyproject.toml

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
[project]
22
name = "llm-messages-token-helper"
33
description = "A helper library for estimating tokens used by messages."
4-
version = "0.0.2"
4+
version = "0.0.3"
55
authors = [{name = "Pamela Fox"}]
66
requires-python = ">=3.9"
77
readme = "README.md"
@@ -33,6 +33,8 @@ dev = [
3333
"ruff",
3434
"black",
3535
"flit",
36+
"azure-identity",
37+
"python-dotenv"
3638
]
3739

3840
[build-system]
@@ -42,9 +44,11 @@ build-backend = "flit_core.buildapi"
4244
[tool.ruff]
4345
line-length = 120
4446
target-version = "py39"
47+
output-format = "full"
48+
49+
[tool.ruff.lint]
4550
select = ["E", "F", "I", "UP"]
4651
ignore = ["D203", "E501"]
47-
show-source = true
4852

4953
[tool.black]
5054
line-length = 120

src/llm_messages_token_helper/model_helper.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,9 @@ def get_token_limit(model: str) -> int:
3535

3636
def count_tokens_for_message(model: str, message: Mapping[str, object]) -> int:
3737
"""
38-
Calculate the number of tokens required to encode a message.
38+
Calculate the number of tokens required to encode a message. Based off cookbook:
39+
https://github.com/openai/openai-cookbook/blob/main/examples/How_to_count_tokens_with_tiktoken.ipynb
40+
3941
Args:
4042
model (str): The name of the model to use for encoding.
4143
message (Mapping): The message to encode, in a dictionary-like object.
@@ -45,16 +47,19 @@ def count_tokens_for_message(model: str, message: Mapping[str, object]) -> int:
4547
>> model = 'gpt-3.5-turbo'
4648
>> message = {'role': 'user', 'content': 'Hello, how are you?'}
4749
>> count_tokens_for_message(model, message)
48-
11
50+
13
4951
"""
5052

5153
encoding = tiktoken.encoding_for_model(get_oai_chatmodel_tiktok(model))
52-
num_tokens = 2 # For "role" and "content" keys
53-
for value in message.values():
54+
# Assumes we're using a recent model
55+
tokens_per_message = 3
56+
57+
num_tokens = tokens_per_message
58+
for key, value in message.items():
5459
if isinstance(value, list):
5560
# For GPT-4-vision support, based on https://github.com/openai/openai-cookbook/pull/881/files
5661
for item in value:
57-
num_tokens += len(encoding.encode(item["type"]))
62+
# Note: item[type] does not seem to be counted in the token count
5863
if item["type"] == "text":
5964
num_tokens += len(encoding.encode(item["text"]))
6065
elif item["type"] == "image_url":
@@ -63,6 +68,9 @@ def count_tokens_for_message(model: str, message: Mapping[str, object]) -> int:
6368
num_tokens += len(encoding.encode(value))
6469
else:
6570
raise ValueError(f"Could not encode unsupported message value type: {type(value)}")
71+
if key == "name":
72+
num_tokens += 1
73+
num_tokens += 3 # every reply is primed with <|start|>assistant<|message|>
6674
return num_tokens
6775

6876

tests/__init__.py

Whitespace-only changes.

tests/messages.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
user_message = {
2+
"message": {
3+
"role": "user",
4+
"content": "Hello, how are you?",
5+
},
6+
"count": 13,
7+
}
8+
9+
user_message_unicode = {
10+
"message": {
11+
"role": "user",
12+
"content": "á",
13+
},
14+
"count": 8,
15+
}
16+
17+
system_message_short = {
18+
"message": {
19+
"role": "system",
20+
"content": "You are a bot.",
21+
},
22+
"count": 12,
23+
}
24+
25+
system_message = {
26+
"message": {
27+
"role": "system",
28+
"content": "You are a helpful, pattern-following assistant that translates corporate jargon into plain English.",
29+
},
30+
"count": 25,
31+
}
32+
33+
system_message_unicode = {
34+
"message": {
35+
"role": "system",
36+
"content": "á",
37+
},
38+
"count": 8,
39+
}
40+
41+
system_message_with_name = {
42+
"message": {
43+
"role": "system",
44+
"name": "example_user",
45+
"content": "New synergies will help drive top-line growth.",
46+
},
47+
"count": 20, # Less tokens in older vision preview models
48+
}
49+
50+
text_and_image_message = {
51+
"message": {
52+
"role": "user",
53+
"content": [
54+
{"type": "text", "text": "Describe this picture:"},
55+
{
56+
"type": "image_url",
57+
"image_url": {
58+
"url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z/C/HgAGgwJ/lK3Q6wAAAABJRU5ErkJggg==",
59+
"detail": "auto",
60+
},
61+
},
62+
],
63+
},
64+
"count": 266,
65+
}

tests/test_messagebuilder.py

Lines changed: 22 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,35 @@
11
from llm_messages_token_helper import build_messages, count_tokens_for_message
22

3+
from .messages import system_message_short, system_message_unicode, user_message, user_message_unicode
4+
35

46
def test_messagebuilder():
5-
messages = build_messages("gpt-35-turbo", "You are a bot.")
6-
assert messages == [
7-
# 1 token, 1 token, 1 token, 5 tokens
8-
{"role": "system", "content": "You are a bot."}
9-
]
10-
assert count_tokens_for_message("gpt-35-turbo", messages[0]) == 8
7+
messages = build_messages("gpt-35-turbo", system_message_short["message"]["content"])
8+
assert messages == [system_message_short["message"]]
9+
assert count_tokens_for_message("gpt-35-turbo", messages[0]) == system_message_short["count"]
1110

1211

1312
def test_messagebuilder_append():
14-
messages = build_messages("gpt-35-turbo", "You are a bot.", new_user_message="Hello, how are you?")
15-
assert messages == [
16-
# 1 token, 1 token, 1 token, 5 tokens
17-
{"role": "system", "content": "You are a bot."},
18-
# 1 token, 1 token, 1 token, 6 tokens
19-
{"role": "user", "content": "Hello, how are you?"},
20-
]
21-
assert count_tokens_for_message("gpt-35-turbo", messages[0]) == 8
22-
assert count_tokens_for_message("gpt-35-turbo", messages[1]) == 9
13+
messages = build_messages(
14+
"gpt-35-turbo", system_message_short["message"]["content"], new_user_message=user_message["message"]["content"]
15+
)
16+
assert messages == [system_message_short["message"], user_message["message"]]
17+
assert count_tokens_for_message("gpt-35-turbo", messages[0]) == system_message_short["count"]
18+
assert count_tokens_for_message("gpt-35-turbo", messages[1]) == user_message["count"]
2319

2420

2521
def test_messagebuilder_unicode():
26-
messages = build_messages("gpt-35-turbo", "a\u0301")
27-
assert messages == [
28-
# 1 token, 1 token, 1 token, 1 token
29-
{"role": "system", "content": "á"}
30-
]
31-
assert count_tokens_for_message("gpt-35-turbo", messages[0]) == 4
22+
messages = build_messages("gpt-35-turbo", system_message_unicode["message"]["content"])
23+
assert messages == [system_message_unicode["message"]]
24+
assert count_tokens_for_message("gpt-35-turbo", messages[0]) == system_message_unicode["count"]
3225

3326

3427
def test_messagebuilder_unicode_append():
35-
messages = build_messages("gpt-35-turbo", "a\u0301", new_user_message="a\u0301")
36-
assert messages == [
37-
# 1 token, 1 token, 1 token, 1 token
38-
{"role": "system", "content": "á"},
39-
# 1 token, 1 token, 1 token, 1 token
40-
{"role": "user", "content": "á"},
41-
]
42-
assert count_tokens_for_message("gpt-35-turbo", messages[0]) == 4
43-
assert count_tokens_for_message("gpt-35-turbo", messages[1]) == 4
28+
messages = build_messages(
29+
"gpt-35-turbo",
30+
system_message_unicode["message"]["content"],
31+
new_user_message=user_message_unicode["message"]["content"],
32+
)
33+
assert messages == [system_message_unicode["message"], user_message_unicode["message"]]
34+
assert count_tokens_for_message("gpt-35-turbo", messages[0]) == system_message_unicode["count"]
35+
assert count_tokens_for_message("gpt-35-turbo", messages[1]) == user_message_unicode["count"]

tests/test_modelhelper.py

Lines changed: 14 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import pytest
22
from llm_messages_token_helper import count_tokens_for_message, get_token_limit
33

4+
from .messages import system_message, system_message_with_name, text_and_image_message, user_message
5+
46

57
def test_get_token_limit():
68
assert get_token_limit("gpt-35-turbo") == 4000
@@ -29,52 +31,26 @@ def test_get_token_limit_error():
2931
"gpt-4v",
3032
],
3133
)
32-
def test_count_tokens_for_message(model: str):
33-
message = {
34-
# 1 token : 1 token
35-
"role": "user",
36-
# 1 token : 5 tokens
37-
"content": "Hello, how are you?",
38-
}
39-
assert count_tokens_for_message(model, message) == 9
40-
41-
42-
def test_count_tokens_for_message_gpt4():
43-
message = {
44-
# 1 token : 1 token
45-
"role": "user",
46-
# 1 token : 5 tokens
47-
"content": "Hello, how are you?",
48-
}
49-
model = "gpt-4"
50-
assert count_tokens_for_message(model, message) == 9
34+
@pytest.mark.parametrize(
35+
"message",
36+
[
37+
user_message,
38+
system_message,
39+
system_message_with_name,
40+
],
41+
)
42+
def test_count_tokens_for_message(model: str, message: dict):
43+
assert count_tokens_for_message(model, message["message"]) == message["count"]
5144

5245

5346
def test_count_tokens_for_message_list():
54-
message = {
55-
# 1 token : 1 token
56-
"role": "user",
57-
# 1 token : 262 tokens
58-
"content": [
59-
{"type": "text", "text": "Describe this picture:"}, # 1 token # 4 tokens
60-
{
61-
"type": "image_url", # 2 tokens
62-
"image_url": {
63-
"url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z/C/HgAGgwJ/lK3Q6wAAAABJRU5ErkJggg==", # 255 tokens
64-
"detail": "auto",
65-
},
66-
},
67-
],
68-
}
6947
model = "gpt-4"
70-
assert count_tokens_for_message(model, message) == 265
48+
assert count_tokens_for_message(model, text_and_image_message["message"]) == text_and_image_message["count"]
7149

7250

7351
def test_count_tokens_for_message_error():
7452
message = {
75-
# 1 token : 1 token
7653
"role": "user",
77-
# 1 token : 5 tokens
7854
"content": {"key": "value"},
7955
}
8056
model = "gpt-35-turbo"
@@ -85,7 +61,7 @@ def test_count_tokens_for_message_error():
8561
def test_get_oai_chatmodel_tiktok_error():
8662
message = {
8763
"role": "user",
88-
"content": {"key": "value"},
64+
"content": "hello",
8965
}
9066
with pytest.raises(ValueError, match="Expected valid OpenAI GPT model name"):
9167
count_tokens_for_message("", message)

tests/verify_openai.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import os
2+
3+
import azure.identity
4+
import openai
5+
from dotenv import load_dotenv
6+
from messages import (
7+
system_message,
8+
system_message_short,
9+
system_message_unicode,
10+
system_message_with_name,
11+
text_and_image_message,
12+
user_message,
13+
user_message_unicode,
14+
)
15+
16+
# Setup the OpenAI client to use either Azure OpenAI or OpenAI API
17+
load_dotenv()
18+
API_HOST = os.getenv("API_HOST")
19+
20+
if API_HOST == "azure":
21+
token_provider = azure.identity.get_bearer_token_provider(
22+
azure.identity.DefaultAzureCredential(), "https://cognitiveservices.azure.com/.default"
23+
)
24+
client = openai.AzureOpenAI(
25+
api_version=os.getenv("AZURE_OPENAI_VERSION"),
26+
azure_endpoint=os.getenv("AZURE_OPENAI_ENDPOINT"),
27+
azure_ad_token_provider=token_provider,
28+
)
29+
MODEL_NAME = os.getenv("AZURE_OPENAI_DEPLOYMENT")
30+
else:
31+
client = openai.OpenAI(api_key=os.getenv("OPENAI_KEY"))
32+
MODEL_NAME = os.getenv("OPENAI_MODEL")
33+
34+
# Test the token count for each message
35+
for message_count_pair in [
36+
user_message,
37+
user_message_unicode,
38+
system_message,
39+
system_message_short,
40+
system_message_unicode,
41+
system_message_with_name,
42+
text_and_image_message,
43+
]:
44+
response = client.chat.completions.create(
45+
model=MODEL_NAME,
46+
temperature=0.7,
47+
n=1,
48+
messages=[message_count_pair["message"]],
49+
)
50+
51+
print(message_count_pair["message"])
52+
expected_tokens = message_count_pair["count"]
53+
assert (
54+
response.usage.prompt_tokens == expected_tokens
55+
), f"Expected {expected_tokens} tokens, got {response.usage.prompt_tokens}"

0 commit comments

Comments
 (0)