unique_toolkit 0.8.13__py3-none-any.whl → 0.8.15__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,6 @@
1
+ from unique_toolkit.language_model.infos import LanguageModelName
2
+
3
+ DEFAULT_GPT_35_TURBO = LanguageModelName.AZURE_GPT_35_TURBO_0125
4
+ DEFAULT_GPT_4o = LanguageModelName.AZURE_GPT_4o_2024_1120
5
+ DEFAULT_GPT_4o_STRUCTURED_OUTPUT = LanguageModelName.AZURE_GPT_4o_2024_0806
6
+ DEFAULT_GPT_4o_MINI = LanguageModelName.AZURE_GPT_4o_MINI_2024_0718
@@ -0,0 +1,67 @@
1
+ import base64
2
+ import math
3
+ import re
4
+ from enum import Enum
5
+ from io import BytesIO
6
+
7
+ from PIL import Image
8
+
9
+
10
+ class DetailLevel(Enum):
11
+ LOW = "low"
12
+ HIGH = "high"
13
+
14
+
15
+ # https://platform.openai.com/docs/guides/vision/calculating-costs#calculating-costs
16
+ def calculate_image_tokens(width, height, detail: DetailLevel):
17
+ """
18
+ Calculate the token cost of an image based on its dimensions and detail level.
19
+ NOTE: While we followed the documentation provided by openai to calculate image token cost, in practice,
20
+ we notice that this function overestimate the number of tokens consumed by the model.
21
+
22
+ Parameters:
23
+ - width (int): The width of the image in pixels.
24
+ - height (int): The height of the image in pixels.
25
+ - detail (str): The detail level, either "low" or "high".
26
+
27
+ Returns:
28
+ - int: The token cost of the image.
29
+ """
30
+ # Base cost for low detail
31
+ if detail == DetailLevel.LOW:
32
+ return 85
33
+
34
+ # Scaling for high detail
35
+ # Scale down to fit within 2048x2048 square
36
+ max_long_dim = 2048
37
+ long_dim = max(width, height)
38
+ if long_dim > max_long_dim:
39
+ scale_factor = long_dim / max_long_dim
40
+ width = int(width / scale_factor)
41
+ height = int(height / scale_factor)
42
+
43
+ # Scale down the shortest side to 768
44
+ max_short_dim = 768
45
+ short_dim = min(width, height)
46
+ if short_dim > max_short_dim:
47
+ scale_factor = short_dim / max_short_dim
48
+ width = int(width / scale_factor)
49
+ height = int(height / scale_factor)
50
+
51
+ # Step 3: Calculate the number of 512x512 tiles
52
+ tiles = math.ceil(width / 512) * math.ceil(height / 512)
53
+ # Step 4: Compute token cost
54
+ token_cost = (tiles * 170) + 85
55
+ return token_cost
56
+
57
+
58
+ def calculate_image_tokens_from_base64(base64_string: str):
59
+ base64_string = remove_base64_header(base64_string)
60
+ image = Image.open(BytesIO(base64.b64decode(base64_string)))
61
+ # DETAIL LEVEL HIGH IS THE DEFAULT TO BE ON THE SAFE SIDE
62
+ return calculate_image_tokens(image.width, image.height, DetailLevel.HIGH)
63
+
64
+
65
+ def remove_base64_header(base64_string: str):
66
+ header_pattern = r"^data:image/\w+;base64,"
67
+ return re.sub(header_pattern, "", base64_string)
@@ -0,0 +1,196 @@
1
+ # Original source
2
+ # https://github.com/openai/openai-cookbook/blob/main/examples/How_to_count_tokens_with_tiktoken.ipynb
3
+
4
+ import json
5
+ from typing import Any, Callable
6
+
7
+ from pydantic import BaseModel
8
+ from unique_toolkit.language_model import (
9
+ LanguageModelMessage,
10
+ LanguageModelMessages,
11
+ LanguageModelName,
12
+ )
13
+
14
+ from _common.utils.token.image_token_counting import (
15
+ calculate_image_tokens_from_base64,
16
+ )
17
+
18
+
19
+ class SpecialToolCallingTokens(BaseModel):
20
+ func_init: int = 0
21
+ prop_init: int = 0
22
+ prop_key: int = 0
23
+ enum_init: int = 0
24
+ enum_item: int = 0
25
+ func_end: int = 0
26
+
27
+
28
+ def get_special_token(model: LanguageModelName) -> SpecialToolCallingTokens:
29
+ special_token = SpecialToolCallingTokens()
30
+
31
+ match model:
32
+ case (
33
+ LanguageModelName.AZURE_GPT_4o_2024_0513
34
+ | LanguageModelName.AZURE_GPT_4o_2024_0806
35
+ | LanguageModelName.AZURE_GPT_4o_MINI_2024_0718
36
+ | LanguageModelName.AZURE_GPT_4o_2024_1120
37
+ ):
38
+ special_token.func_init = 7
39
+ special_token.prop_init = 3
40
+ special_token.prop_key = 3
41
+ special_token.enum_init = -3
42
+ special_token.enum_item = 3
43
+ special_token.func_end = 12
44
+
45
+ case (
46
+ LanguageModelName.AZURE_GPT_35_TURBO_0125
47
+ | LanguageModelName.AZURE_GPT_4_0613
48
+ | LanguageModelName.AZURE_GPT_4_32K_0613
49
+ | LanguageModelName.AZURE_GPT_4_TURBO_2024_0409
50
+ ):
51
+ special_token.func_init = 10
52
+ special_token.prop_init = 3
53
+ special_token.prop_key = 3
54
+ special_token.enum_init = -3
55
+ special_token.enum_item = 3
56
+ special_token.func_end = 12
57
+
58
+ case _:
59
+ raise NotImplementedError(
60
+ f"""num_tokens_for_tools() is not implemented for model {model}."""
61
+ )
62
+ return special_token
63
+
64
+
65
+ def num_tokens_per_messages(
66
+ messages: list[dict[str, str]], encode: Callable[[str], list[int]]
67
+ ) -> list[int]:
68
+ """Return the number of tokens used by a list of messages."""
69
+
70
+ num_token_per_message = []
71
+ for message in messages:
72
+ num_tokens = 3 # extra_tokens_per_message
73
+ for key, value in message.items():
74
+ if isinstance(value, str):
75
+ num_tokens += len(encode(value))
76
+ elif isinstance(value, list):
77
+ # NOTE: The result returned by the function below is not 100% accurate.
78
+ num_tokens += handle_message_with_images(value, encode)
79
+ if key == "name":
80
+ num_tokens += 1 # extra_tokens_per_name
81
+
82
+ num_token_per_message.append(num_tokens)
83
+
84
+ return num_token_per_message
85
+
86
+
87
+ def num_tokens_from_messages(
88
+ messages: list[dict[str, str]], encode: Callable[[str], list[int]]
89
+ ) -> int:
90
+ """Return the number of tokens used by a list of messages."""
91
+
92
+ num_tokens_per_message = num_tokens_per_messages(messages, encode)
93
+ num_tokens = sum(num_tokens_per_message) + 3
94
+
95
+ return num_tokens
96
+
97
+
98
+ def num_tokens_for_tools(
99
+ functions: list[dict[str, Any]],
100
+ special_token: SpecialToolCallingTokens,
101
+ encode: Callable[[str], list[int]],
102
+ ):
103
+ def num_token_function_enum(
104
+ properties: dict[str, Any], encode: Callable[[str], list[int]]
105
+ ):
106
+ enum_token_count = 0
107
+ enum_token_count += special_token.enum_init
108
+ for item in properties[key]["enum"]:
109
+ enum_token_count += special_token.enum_item
110
+ enum_token_count += len(encode(item))
111
+
112
+ return enum_token_count
113
+
114
+ func_token_count = 0
115
+ if len(functions) > 0:
116
+ for func in functions:
117
+ func_token_count += special_token.func_init
118
+ function = func.get("function", {})
119
+ func_token_count += len(
120
+ encode(
121
+ function.get("name", "")
122
+ + ":"
123
+ + function.get("description", "").rstrip(".").rstrip()
124
+ )
125
+ )
126
+ if len(function.get("parameters", {}).get("properties", "")) > 0:
127
+ properties = function.get("parameters", {}).get(
128
+ "properties", ""
129
+ )
130
+ func_token_count += special_token.prop_init
131
+
132
+ for key in list(properties.keys()):
133
+ func_token_count += special_token.prop_key
134
+
135
+ if "enum" in properties[key].keys():
136
+ func_token_count += num_token_function_enum(
137
+ properties, encode
138
+ )
139
+
140
+ func_token_count += len(
141
+ encode(
142
+ f"{key}:{properties[key]['type']}:{properties[key]['description'].rstrip('.').rstrip()}"
143
+ )
144
+ )
145
+
146
+ func_token_count += special_token.func_end
147
+
148
+ return func_token_count
149
+
150
+
151
+ def handle_message_with_images(
152
+ message: list[dict], encode: Callable[[str], list[int]]
153
+ ):
154
+ token_count = 0
155
+ for item in message:
156
+ if item.get("type") == "image_url":
157
+ image_url = item.get("imageUrl", {}).get("url")
158
+ if image_url:
159
+ token_count += calculate_image_tokens_from_base64(image_url)
160
+ elif item.get("type") == "text":
161
+ token_count += len(encode(item.get("text", "")))
162
+ return token_count
163
+
164
+
165
+ def messages_to_openai_messages(
166
+ messages: LanguageModelMessages | list[LanguageModelMessage],
167
+ ):
168
+ if isinstance(messages, list):
169
+ messages = LanguageModelMessages(messages)
170
+
171
+ return [
172
+ {
173
+ k: v
174
+ for k, v in m.items()
175
+ if (k in ["content", "role"] and v is not None)
176
+ }
177
+ for m in json.loads(messages.model_dump_json())
178
+ ]
179
+
180
+
181
+ def num_tokens_per_language_model_message(
182
+ messages: LanguageModelMessages | list[LanguageModelMessage],
183
+ encode: Callable[[str], list[int]],
184
+ ) -> list[int]:
185
+ return num_tokens_per_messages(
186
+ messages=messages_to_openai_messages(messages), encode=encode
187
+ )
188
+
189
+
190
+ def num_token_for_language_model_messages(
191
+ messages: LanguageModelMessages | list[LanguageModelMessage],
192
+ encode: Callable[[str], list[int]],
193
+ ) -> int:
194
+ return num_tokens_from_messages(
195
+ messages_to_openai_messages(messages), encode
196
+ )
@@ -0,0 +1,307 @@
1
+ import base64
2
+ import mimetypes
3
+
4
+ from datetime import datetime
5
+ from enum import StrEnum
6
+
7
+ import numpy as np
8
+ import tiktoken
9
+
10
+ from pydantic import RootModel
11
+
12
+ from _common.token.token_counting import num_tokens_per_language_model_message
13
+ from chat.service import ChatService
14
+ from content.service import ContentService
15
+ from language_model.schemas import LanguageModelMessages
16
+ from unique_toolkit.app import ChatEventUserMessage
17
+ from unique_toolkit.chat.schemas import ChatMessage
18
+ from unique_toolkit.chat.schemas import ChatMessageRole as ChatRole
19
+ from unique_toolkit.content.schemas import Content
20
+ from unique_toolkit.language_model import LanguageModelMessageRole as LLMRole
21
+ from unique_toolkit.language_model.infos import EncoderName
22
+
23
+
24
+
25
+ # TODO: Test this once it moves into the unique toolkit
26
+
27
+ map_chat_llm_message_role = {
28
+ ChatRole.USER: LLMRole.USER,
29
+ ChatRole.ASSISTANT: LLMRole.ASSISTANT,
30
+ }
31
+
32
+
33
+ class ImageMimeType(StrEnum):
34
+ JPEG = "image/jpeg"
35
+ PNG = "image/png"
36
+ GIF = "image/gif"
37
+ BMP = "image/bmp"
38
+ WEBP = "image/webp"
39
+ TIFF = "image/tiff"
40
+ SVG = "image/svg+xml"
41
+
42
+
43
+ class FileMimeType(StrEnum):
44
+ PDF = "application/pdf"
45
+ DOCX = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
46
+ DOC = "application/msword"
47
+ XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
48
+ XLS = "application/vnd.ms-excel"
49
+ PPTX = "application/vnd.openxmlformats-officedocument.presentationml.presentation"
50
+ CSV = "text/csv"
51
+ HTML = "text/html"
52
+ MD = "text/markdown"
53
+ TXT = "text/plain"
54
+
55
+
56
+ class ChatMessageWithContents(ChatMessage):
57
+ contents: list[Content] = []
58
+
59
+
60
+ class ChatHistoryWithContent(RootModel):
61
+ root: list[ChatMessageWithContents]
62
+
63
+ @classmethod
64
+ def from_chat_history_and_contents(
65
+ cls,
66
+ chat_history: list[ChatMessage],
67
+ chat_contents: list[Content],
68
+ ):
69
+ combined = chat_contents + chat_history
70
+ combined.sort(key=lambda x: x.created_at or datetime.min)
71
+
72
+ grouped_elements = []
73
+ content_container = []
74
+
75
+ # Content is collected and added to the next chat message
76
+ for c in combined:
77
+ if isinstance(c, ChatMessage):
78
+ grouped_elements.append(
79
+ ChatMessageWithContents(
80
+ contents=content_container.copy(),
81
+ **c.model_dump(),
82
+ ),
83
+ )
84
+ content_container.clear()
85
+ else:
86
+ content_container.append(c)
87
+
88
+ return cls(root=grouped_elements)
89
+
90
+ def __iter__(self):
91
+ return iter(self.root)
92
+
93
+ def __getitem__(self, item):
94
+ return self.root[item]
95
+
96
+
97
+ def is_image_content(filename: str) -> bool:
98
+ mimetype, _ = mimetypes.guess_type(filename)
99
+
100
+ if not mimetype:
101
+ return False
102
+
103
+ return mimetype in ImageMimeType.__members__.values()
104
+
105
+
106
+ def is_file_content(filename: str) -> bool:
107
+ mimetype, _ = mimetypes.guess_type(filename)
108
+
109
+ if not mimetype:
110
+ return False
111
+
112
+ return mimetype in FileMimeType.__members__.values()
113
+
114
+
115
+ def get_chat_history_with_contents(
116
+ user_message: ChatEventUserMessage,
117
+ chat_id: str,
118
+ chat_history: list[ChatMessage],
119
+ content_service: ContentService,
120
+ ) -> ChatHistoryWithContent:
121
+ last_user_message = ChatMessage(
122
+ id=user_message.id,
123
+ chat_id=chat_id,
124
+ text=user_message.text,
125
+ originalText=user_message.original_text,
126
+ role=ChatRole.USER,
127
+ gpt_request=None,
128
+ created_at=datetime.fromisoformat(user_message.created_at),
129
+ )
130
+ if len(chat_history) > 0 and last_user_message.id == chat_history[-1].id:
131
+ pass
132
+ else:
133
+ chat_history.append(last_user_message)
134
+
135
+ chat_contents = content_service.search_contents(
136
+ where={
137
+ "ownerId": {
138
+ "equals": chat_id,
139
+ },
140
+ },
141
+ )
142
+
143
+ return ChatHistoryWithContent.from_chat_history_and_contents(
144
+ chat_history,
145
+ chat_contents,
146
+ )
147
+
148
+
149
+ def download_encoded_images(
150
+ contents: list[Content],
151
+ content_service: ContentService,
152
+ chat_id: str,
153
+ ) -> list[str]:
154
+ base64_encoded_images = []
155
+ for im in contents:
156
+ if is_image_content(im.key):
157
+ try:
158
+ file_bytes = content_service.download_content_to_bytes(
159
+ content_id=im.id,
160
+ chat_id=chat_id,
161
+ )
162
+
163
+ mime_type, _ = mimetypes.guess_type(im.key)
164
+ encoded_string = base64.b64encode(file_bytes).decode("utf-8")
165
+ image_string = f"data:{mime_type};base64," + encoded_string
166
+ base64_encoded_images.append(image_string)
167
+ except Exception as e:
168
+ print(e)
169
+ return base64_encoded_images
170
+
171
+
172
+ class FileContentSerialization(StrEnum):
173
+ NONE = "none"
174
+ FILE_NAME = "file_name"
175
+
176
+
177
+ class ImageContentInclusion(StrEnum):
178
+ NONE = "none"
179
+ ALL = "all"
180
+
181
+
182
+ def file_content_serialization(
183
+ file_contents: list[Content],
184
+ file_content_serialization: FileContentSerialization,
185
+ ) -> str:
186
+ match file_content_serialization:
187
+ case FileContentSerialization.NONE:
188
+ return ""
189
+ case FileContentSerialization.FILE_NAME:
190
+ file_names = [
191
+ f"- Uploaded file: {f.key} at {f.created_at}"
192
+ for f in file_contents
193
+ ]
194
+ return "\n".join(
195
+ [
196
+ "Files Uploaded to Chat can be accessed by internal search tool if available:\n",
197
+ ]
198
+ + file_names,
199
+ )
200
+
201
+
202
+ def get_full_history_with_contents(
203
+ user_message: ChatEventUserMessage,
204
+ chat_id: str,
205
+ chat_service: ChatService,
206
+ content_service: ContentService,
207
+ include_images: ImageContentInclusion = ImageContentInclusion.ALL,
208
+ file_content_serialization_type: FileContentSerialization = FileContentSerialization.FILE_NAME,
209
+ ) -> LanguageModelMessages:
210
+ grouped_elements = get_chat_history_with_contents(
211
+ user_message=user_message,
212
+ chat_id=chat_id,
213
+ chat_history=chat_service.get_full_history(),
214
+ content_service=content_service,
215
+ )
216
+
217
+ builder = LanguageModelMessages([]).builder()
218
+ for c in grouped_elements:
219
+ # LanguageModelUserMessage has not field original content
220
+ text = c.original_content if c.original_content else c.content
221
+ if text is None:
222
+ if c.role == ChatRole.USER:
223
+ raise ValueError(
224
+ "Content or original_content of LanguageModelMessages should exist.",
225
+ )
226
+ text = ""
227
+
228
+ if len(c.contents) > 0:
229
+ file_contents = [
230
+ co for co in c.contents if is_file_content(co.key)
231
+ ]
232
+ image_contents = [
233
+ co for co in c.contents if is_image_content(co.key)
234
+ ]
235
+
236
+ content = (
237
+ text
238
+ + "\n\n"
239
+ + file_content_serialization(
240
+ file_contents,
241
+ file_content_serialization_type,
242
+ )
243
+ )
244
+ content = content.strip()
245
+
246
+ if include_images and len(image_contents) > 0:
247
+ builder.image_message_append(
248
+ content=content,
249
+ images=download_encoded_images(
250
+ contents=image_contents,
251
+ content_service=content_service,
252
+ chat_id=chat_id,
253
+ ),
254
+ role=map_chat_llm_message_role[c.role],
255
+ )
256
+ else:
257
+ builder.message_append(
258
+ role=map_chat_llm_message_role[c.role],
259
+ content=content,
260
+ )
261
+ else:
262
+ builder.message_append(
263
+ role=map_chat_llm_message_role[c.role],
264
+ content=text,
265
+ )
266
+ return builder.build()
267
+
268
+
269
+ def get_full_history_as_llm_messages(
270
+ chat_service: ChatService,
271
+ ) -> LanguageModelMessages:
272
+ chat_history = chat_service.get_full_history()
273
+
274
+ map_chat_llm_message_role = {
275
+ ChatRole.USER: LLMRole.USER,
276
+ ChatRole.ASSISTANT: LLMRole.ASSISTANT,
277
+ }
278
+
279
+ builder = LanguageModelMessages([]).builder()
280
+ for c in chat_history:
281
+ builder.message_append(
282
+ role=map_chat_llm_message_role[c.role],
283
+ content=c.content or "",
284
+ )
285
+ return builder.build()
286
+
287
+
288
+
289
+ def limit_to_token_window(
290
+ messages: LanguageModelMessages,
291
+ token_limit: int,
292
+ encoding_name: EncoderName = EncoderName.O200K_BASE,
293
+ ) -> LanguageModelMessages:
294
+ encoder = tiktoken.get_encoding(encoding_name)
295
+ token_per_message_reversed = num_tokens_per_language_model_message(
296
+ messages,
297
+ encode=encoder.encode,
298
+ )
299
+
300
+ to_take: list[bool] = (
301
+ np.cumsum(token_per_message_reversed) < token_limit
302
+ ).tolist()
303
+ to_take.reverse()
304
+
305
+ return LanguageModelMessages(
306
+ root=[m for m, tt in zip(messages, to_take, strict=False) if tt],
307
+ )