youtu-agent 0.1.2__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.
- utils/__init__.py +35 -0
- utils/agents_utils.py +359 -0
- utils/common.py +82 -0
- utils/env.py +34 -0
- utils/image.py +16 -0
- utils/llm_output_parser.py +64 -0
- utils/log.py +79 -0
- utils/openai_utils/__init__.py +5 -0
- utils/openai_utils/openai_utils.py +147 -0
- utils/openai_utils/simplified_client.py +119 -0
- utils/openai_utils/types.py +66 -0
- utils/path.py +130 -0
- utils/print_utils.py +112 -0
- utils/script_utils.py +22 -0
- utils/sqlmodel_utils.py +95 -0
- utils/token.py +21 -0
- utils/tool_cache.py +121 -0
- youtu_agent-0.1.2.dist-info/METADATA +439 -0
- youtu_agent-0.1.2.dist-info/RECORD +21 -0
- youtu_agent-0.1.2.dist-info/WHEEL +4 -0
- youtu_agent-0.1.2.dist-info/licenses/LICENSE +28 -0
utils/__init__.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from .agents_utils import AgentsUtils, ChatCompletionConverter
|
|
2
|
+
from .common import get_event_loop, load_class_from_file, schema_to_basemodel
|
|
3
|
+
from .env import EnvUtils
|
|
4
|
+
from .image import encode_image
|
|
5
|
+
from .llm_output_parser import LLMOutputParser
|
|
6
|
+
from .log import get_logger, oneline_object, setup_logging
|
|
7
|
+
from .openai_utils import OpenAIUtils, SimplifiedAsyncOpenAI
|
|
8
|
+
from .path import CACHE_DIR, DIR_ROOT, FileUtils
|
|
9
|
+
from .print_utils import PrintUtils
|
|
10
|
+
from .sqlmodel_utils import SQLModelUtils
|
|
11
|
+
from .token import TokenUtils
|
|
12
|
+
from .tool_cache import async_file_cache
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"PrintUtils",
|
|
16
|
+
"SimplifiedAsyncOpenAI",
|
|
17
|
+
"OpenAIUtils",
|
|
18
|
+
"AgentsUtils",
|
|
19
|
+
"ChatCompletionConverter",
|
|
20
|
+
"oneline_object",
|
|
21
|
+
"setup_logging",
|
|
22
|
+
"schema_to_basemodel",
|
|
23
|
+
"load_class_from_file",
|
|
24
|
+
"get_logger",
|
|
25
|
+
"SQLModelUtils",
|
|
26
|
+
"async_file_cache",
|
|
27
|
+
"DIR_ROOT",
|
|
28
|
+
"FileUtils",
|
|
29
|
+
"CACHE_DIR",
|
|
30
|
+
"TokenUtils",
|
|
31
|
+
"get_event_loop",
|
|
32
|
+
"EnvUtils",
|
|
33
|
+
"LLMOutputParser",
|
|
34
|
+
"encode_image",
|
|
35
|
+
]
|
utils/agents_utils.py
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import uuid
|
|
5
|
+
from collections.abc import AsyncIterator, Iterable
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
from agents import (
|
|
9
|
+
FunctionTool,
|
|
10
|
+
HandoffOutputItem,
|
|
11
|
+
ItemHelpers,
|
|
12
|
+
MessageOutputItem,
|
|
13
|
+
Model,
|
|
14
|
+
ModelSettings,
|
|
15
|
+
ModelTracing,
|
|
16
|
+
OpenAIChatCompletionsModel,
|
|
17
|
+
OpenAIResponsesModel,
|
|
18
|
+
ReasoningItem,
|
|
19
|
+
RunItem,
|
|
20
|
+
RunResult,
|
|
21
|
+
StreamEvent,
|
|
22
|
+
ToolCallItem,
|
|
23
|
+
ToolCallOutputItem,
|
|
24
|
+
TResponseInputItem,
|
|
25
|
+
)
|
|
26
|
+
from agents.models.chatcmpl_converter import Converter
|
|
27
|
+
from agents.stream_events import AgentUpdatedStreamEvent, RawResponsesStreamEvent, RunItemStreamEvent
|
|
28
|
+
from agents.tracing import Trace, gen_trace_id, get_current_trace
|
|
29
|
+
from openai import AsyncOpenAI
|
|
30
|
+
from openai.types.chat import ChatCompletionMessageParam, ChatCompletionToolParam
|
|
31
|
+
from openai.types.responses import ResponseFunctionToolCall
|
|
32
|
+
|
|
33
|
+
from .image import encode_image
|
|
34
|
+
from .openai_utils import OpenAIChatCompletionParams
|
|
35
|
+
from .print_utils import PrintUtils
|
|
36
|
+
|
|
37
|
+
logger = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ChatCompletionConverter(Converter):
|
|
41
|
+
@classmethod
|
|
42
|
+
def items_to_messages(cls, items: str | Iterable[TResponseInputItem]) -> list[ChatCompletionMessageParam]:
|
|
43
|
+
# skip reasoning, see chatcmpl_converter.Converter.items_to_messages()
|
|
44
|
+
# agents.exceptions.UserError: Unhandled item type or structure:
|
|
45
|
+
# {'id': '__fake_id__', 'summary': [{'text': '...', 'type': 'summary_text'}], 'type': 'reasoning'}
|
|
46
|
+
if not isinstance(items, str): # TODO: check it!
|
|
47
|
+
items = cls.filter_items(items)
|
|
48
|
+
return Converter.items_to_messages(items)
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def filter_items(cls, items: str | Iterable[TResponseInputItem]) -> str | list[TResponseInputItem]:
|
|
52
|
+
if isinstance(items, str):
|
|
53
|
+
return items
|
|
54
|
+
filtered_items = []
|
|
55
|
+
for item in items:
|
|
56
|
+
if item.get("type", None) == "reasoning":
|
|
57
|
+
continue
|
|
58
|
+
filtered_items.append(item)
|
|
59
|
+
return filtered_items
|
|
60
|
+
|
|
61
|
+
@classmethod
|
|
62
|
+
def items_to_dict(cls, items: str | Iterable[TResponseInputItem]) -> list[dict]:
|
|
63
|
+
"""convert items to a list of dict which have {"role", "content"}
|
|
64
|
+
WIP!
|
|
65
|
+
"""
|
|
66
|
+
if isinstance(items, str):
|
|
67
|
+
return [{"role": "user", "content": items}]
|
|
68
|
+
result = []
|
|
69
|
+
for item in items:
|
|
70
|
+
if msg := Converter.maybe_easy_input_message(item):
|
|
71
|
+
result.append(msg)
|
|
72
|
+
elif msg := Converter.maybe_input_message(item):
|
|
73
|
+
result.append(msg)
|
|
74
|
+
elif msg := Converter.maybe_response_output_message(item):
|
|
75
|
+
result.append(msg)
|
|
76
|
+
elif msg := Converter.maybe_file_search_call(item):
|
|
77
|
+
msg.update({"role": "tool", "content": msg["results"]})
|
|
78
|
+
result.append(msg)
|
|
79
|
+
elif msg := Converter.maybe_function_tool_call(item):
|
|
80
|
+
msg.update({"role": "assistant", "content": f"{msg['name']}({msg['arguments']})"})
|
|
81
|
+
result.append(msg)
|
|
82
|
+
elif msg := Converter.maybe_function_tool_call_output(item):
|
|
83
|
+
msg.update({"role": "tool", "content": msg["output"], "tool_call_id": msg["call_id"]})
|
|
84
|
+
result.append(msg)
|
|
85
|
+
elif msg := Converter.maybe_reasoning_message(item):
|
|
86
|
+
msg.update({"role": "assistant", "content": msg["summary"]})
|
|
87
|
+
result.append(msg)
|
|
88
|
+
else:
|
|
89
|
+
logger.warning(f"Unknown message type: {item}")
|
|
90
|
+
result.append({"role": "assistant", "content": f"Unknown message type: {item}"})
|
|
91
|
+
return result
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class AgentsUtils:
|
|
95
|
+
"""Utils for openai-agents SDK"""
|
|
96
|
+
|
|
97
|
+
@staticmethod
|
|
98
|
+
def generate_group_id() -> str:
|
|
99
|
+
"""Generate a unique group ID. (Used in OpenAI tracing)
|
|
100
|
+
Ref: https://openai.github.io/openai-agents-python/tracing/
|
|
101
|
+
"""
|
|
102
|
+
return uuid.uuid4().hex[:16]
|
|
103
|
+
|
|
104
|
+
@staticmethod
|
|
105
|
+
def gen_trace_id() -> str:
|
|
106
|
+
return gen_trace_id()
|
|
107
|
+
|
|
108
|
+
@staticmethod
|
|
109
|
+
def get_current_trace() -> Trace:
|
|
110
|
+
return get_current_trace()
|
|
111
|
+
|
|
112
|
+
@staticmethod
|
|
113
|
+
def get_agents_model(
|
|
114
|
+
type: Literal["responses", "chat.completions", "litellm"] = None,
|
|
115
|
+
model: str = None,
|
|
116
|
+
base_url: str = None,
|
|
117
|
+
api_key: str = None,
|
|
118
|
+
) -> Model:
|
|
119
|
+
type = type or os.getenv("UTU_LLM_TYPE", "chat.completions")
|
|
120
|
+
model = model or os.getenv("UTU_LLM_MODEL")
|
|
121
|
+
if type == "litellm":
|
|
122
|
+
# Ref: https://docs.litellm.ai/docs/providers
|
|
123
|
+
# NOTE: should set .evn properly! e.g. AZURE_API_KEY, AZURE_API_BASE, AZURE_API_VERSION for Azure
|
|
124
|
+
# https://docs.litellm.ai/docs/providers/azure/
|
|
125
|
+
from agents.extensions.models.litellm_model import LitellmModel
|
|
126
|
+
|
|
127
|
+
return LitellmModel(model=model)
|
|
128
|
+
|
|
129
|
+
base_url = base_url or os.getenv("UTU_LLM_BASE_URL")
|
|
130
|
+
api_key = api_key or os.getenv("UTU_LLM_API_KEY")
|
|
131
|
+
if not api_key or not base_url:
|
|
132
|
+
raise ValueError("UTU_LLM_API_KEY and UTU_LLM_BASE_URL must be set")
|
|
133
|
+
openai_client = AsyncOpenAI(
|
|
134
|
+
api_key=api_key,
|
|
135
|
+
base_url=base_url,
|
|
136
|
+
timeout=100,
|
|
137
|
+
)
|
|
138
|
+
if type == "chat.completions":
|
|
139
|
+
return OpenAIChatCompletionsModel(model=model, openai_client=openai_client)
|
|
140
|
+
elif type == "responses":
|
|
141
|
+
return OpenAIResponsesModel(model=model, openai_client=openai_client)
|
|
142
|
+
else:
|
|
143
|
+
raise ValueError("Invalid type: " + type)
|
|
144
|
+
|
|
145
|
+
@staticmethod
|
|
146
|
+
def get_trajectory_from_agent_result(agent_result: RunResult, agent_name: str = None) -> dict:
|
|
147
|
+
if agent_name is None:
|
|
148
|
+
agent_name = agent_result.last_agent.name
|
|
149
|
+
return {
|
|
150
|
+
"agent": agent_name,
|
|
151
|
+
"trajectory": ChatCompletionConverter.items_to_messages(agent_result.to_input_list()),
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
@staticmethod
|
|
155
|
+
def print_new_items(new_items: list[RunItem]) -> None:
|
|
156
|
+
"""Print new items generated by Runner.run()"""
|
|
157
|
+
for new_item in new_items:
|
|
158
|
+
agent_name = new_item.agent.name
|
|
159
|
+
if isinstance(new_item, MessageOutputItem):
|
|
160
|
+
PrintUtils.print_bot(f"{agent_name}: {ItemHelpers.text_message_output(new_item)}")
|
|
161
|
+
elif isinstance(new_item, HandoffOutputItem):
|
|
162
|
+
PrintUtils.print_info(f"Handed off from {new_item.source_agent.name} to {new_item.target_agent.name}")
|
|
163
|
+
elif isinstance(new_item, ToolCallItem):
|
|
164
|
+
assert isinstance(new_item.raw_item, ResponseFunctionToolCall) # DONOT use openai's built-in tools
|
|
165
|
+
PrintUtils.print_info(
|
|
166
|
+
f"{agent_name}: Calling a tool: {new_item.raw_item.name}({json.loads(new_item.raw_item.arguments)})"
|
|
167
|
+
)
|
|
168
|
+
elif isinstance(new_item, ToolCallOutputItem):
|
|
169
|
+
PrintUtils.print_tool(f"Tool call output: {new_item.output}")
|
|
170
|
+
elif isinstance(new_item, ReasoningItem):
|
|
171
|
+
PrintUtils.print_info(f"{agent_name}: Reasoning: {new_item.raw_item}")
|
|
172
|
+
else:
|
|
173
|
+
PrintUtils.print_info(f"{agent_name}: Skipping item: {new_item.__class__.__name__}")
|
|
174
|
+
|
|
175
|
+
@staticmethod
|
|
176
|
+
async def print_stream_events(result: AsyncIterator[StreamEvent]) -> None:
|
|
177
|
+
"""Print stream events generated by Runner.run_streamed()"""
|
|
178
|
+
async for event in result:
|
|
179
|
+
# print(f"> [DEBUG] event: {event}")
|
|
180
|
+
if isinstance(event, RawResponsesStreamEvent):
|
|
181
|
+
# event.data -- ResponseStreamEvent
|
|
182
|
+
if event.data.type == "response.output_item.added":
|
|
183
|
+
match event.data.item.type:
|
|
184
|
+
# computer_call, code_interpreter_call, custom_tool_call, file_search_call, function_call,
|
|
185
|
+
# we_search_call, image_generation_call, local_shell_call,
|
|
186
|
+
# mcp_call, mcp_list_tools, mcp_approval_request, message, reasoning
|
|
187
|
+
case "message":
|
|
188
|
+
pass
|
|
189
|
+
case "function_call":
|
|
190
|
+
PrintUtils.print_bot(
|
|
191
|
+
f"<toolcall name={event.data.item.name}>{event.data.item.arguments}", end=""
|
|
192
|
+
)
|
|
193
|
+
case _:
|
|
194
|
+
PrintUtils.print_bot(f"<{event.data.item.type}>", end="")
|
|
195
|
+
elif event.data.type == "response.output_item.done":
|
|
196
|
+
match event.data.item.type:
|
|
197
|
+
case "message":
|
|
198
|
+
pass
|
|
199
|
+
# PrintUtils.print_bot("") # add a new line?
|
|
200
|
+
case "function_call":
|
|
201
|
+
PrintUtils.print_bot("</toolcall>")
|
|
202
|
+
case _:
|
|
203
|
+
# PrintUtils.print_bot(f"</{event.data.item.type}>")
|
|
204
|
+
logger.info(f"</{event.data.item.type}>") # It seems that vllm's output order is wrong
|
|
205
|
+
elif event.data.type == "response.content_part.added":
|
|
206
|
+
match event.data.part.type:
|
|
207
|
+
# output_text, refusal
|
|
208
|
+
case "output_text":
|
|
209
|
+
pass
|
|
210
|
+
case "refusal":
|
|
211
|
+
PrintUtils.print_bot(f"<refusal>{event.data.part.refusal}", end="")
|
|
212
|
+
case _:
|
|
213
|
+
logger.warning(f"Unknown part type: {event.data.part.type}! {event}")
|
|
214
|
+
elif event.data.type == "response.content_part.done":
|
|
215
|
+
match event.data.part.type:
|
|
216
|
+
case "output_text":
|
|
217
|
+
pass
|
|
218
|
+
case "refusal":
|
|
219
|
+
PrintUtils.print_bot("</refusal>")
|
|
220
|
+
case _:
|
|
221
|
+
logger.warning(f"Unknown part type: {event.data.part.type}! {event}")
|
|
222
|
+
elif event.data.type == "response.reasoning_summary_part.added":
|
|
223
|
+
PrintUtils.print_info("<reasoning_summary>", end="")
|
|
224
|
+
elif event.data.type == "response.reasoning_summary_part.done":
|
|
225
|
+
# PrintUtils.print_info("</reasoning_summary>", end="")
|
|
226
|
+
logger.info("</reasoning_summary>") # It seems that vllm's output order is wrong
|
|
227
|
+
elif event.data.type == "response.reasoning_summary_text.delta":
|
|
228
|
+
PrintUtils.print_info(f"{event.data.delta}", end="")
|
|
229
|
+
elif event.data.type == "response.function_call_arguments.delta":
|
|
230
|
+
PrintUtils.print_bot(f"{event.data.delta}", end="")
|
|
231
|
+
elif event.data.type == "response.function_call_arguments.done":
|
|
232
|
+
pass
|
|
233
|
+
elif event.data.type == "response.output_text.delta":
|
|
234
|
+
PrintUtils.print_bot(f"{event.data.delta}", end="")
|
|
235
|
+
elif event.data.type == "response.reasoning_text.delta":
|
|
236
|
+
PrintUtils.print_info(f"{event.data.delta}", end="")
|
|
237
|
+
elif event.data.type == "response.reasoning_text.done":
|
|
238
|
+
PrintUtils.print_info("</reasoning_text>", end="")
|
|
239
|
+
elif event.data.type in ("response.output_text.done",):
|
|
240
|
+
PrintUtils.print_info("")
|
|
241
|
+
elif event.data.type in (
|
|
242
|
+
"response.created",
|
|
243
|
+
"response.completed",
|
|
244
|
+
"response.in_progress",
|
|
245
|
+
):
|
|
246
|
+
pass
|
|
247
|
+
else:
|
|
248
|
+
PrintUtils.print_info(f"Unknown event type: {event.data.type}! {event}")
|
|
249
|
+
# raise ValueError(f"Unknown event type: {event.data.type}")
|
|
250
|
+
elif isinstance(event, RunItemStreamEvent):
|
|
251
|
+
item: RunItem = event.item
|
|
252
|
+
if item.type == "message_output_item":
|
|
253
|
+
pass # do not print twice to avoid duplicate! (already handled `response.output_text.delta`)
|
|
254
|
+
# PrintUtils.print_bot(f"{ItemHelpers.text_message_output(item).strip()}")
|
|
255
|
+
elif item.type == "reasoning_item":
|
|
256
|
+
pass
|
|
257
|
+
elif item.type == "tool_call_item":
|
|
258
|
+
pass
|
|
259
|
+
# PrintUtils.print_bot([tool_call] {item.raw_item.name}({item.raw_item.arguments})")
|
|
260
|
+
elif item.type == "tool_call_output_item":
|
|
261
|
+
PrintUtils.print_tool(f"[tool_output] {item.output}") # item.raw_item
|
|
262
|
+
elif item.type == "handoff_call_item": # same as `ToolCallItem`
|
|
263
|
+
PrintUtils.print_bot(f"[handoff_call] {item.raw_item.name}({item.raw_item.arguments})")
|
|
264
|
+
elif item.type == "handoff_output_item":
|
|
265
|
+
PrintUtils.print_info(f">> Handoff from {item.source_agent.name} to {item.target_agent.name}")
|
|
266
|
+
elif event.type in (
|
|
267
|
+
"mcp_list_tools_item",
|
|
268
|
+
"mcp_approval_request_item",
|
|
269
|
+
"mcp_approval_response_item",
|
|
270
|
+
):
|
|
271
|
+
PrintUtils.print_info(f" >>> Skipping item: {event}")
|
|
272
|
+
else:
|
|
273
|
+
PrintUtils.print_info(f" >>> Skipping item: {item.__class__.__name__}")
|
|
274
|
+
elif isinstance(event, AgentUpdatedStreamEvent):
|
|
275
|
+
PrintUtils.print_info(f">> new agent: {event.new_agent.name}")
|
|
276
|
+
# skip events from youtu-agent
|
|
277
|
+
elif event.type in ("orchestrator_stream_event", "orchestra_stream_event"):
|
|
278
|
+
pass
|
|
279
|
+
else:
|
|
280
|
+
logger.warning(f"Unknown event type: {event.type}! {event}")
|
|
281
|
+
print() # Newline after stream?
|
|
282
|
+
|
|
283
|
+
@staticmethod
|
|
284
|
+
def convert_model_settings(params: OpenAIChatCompletionParams) -> ModelSettings:
|
|
285
|
+
# "tools", "messages", "model"
|
|
286
|
+
# FIXME: move to extra_args
|
|
287
|
+
for p in ("max_completion_tokens", "top_logprobs", "logprobs", "seed", "stop"):
|
|
288
|
+
if p in params:
|
|
289
|
+
logger.warning(f"Parameter `{p}` is not supported in ModelSettings")
|
|
290
|
+
return ModelSettings(
|
|
291
|
+
max_tokens=params.get("max_tokens", None),
|
|
292
|
+
temperature=params.get("temperature", None),
|
|
293
|
+
top_p=params.get("top_p", None),
|
|
294
|
+
frequency_penalty=params.get("frequency_penalty", None),
|
|
295
|
+
presence_penalty=params.get("presence_penalty", None),
|
|
296
|
+
tool_choice=params.get("tool_choice", None),
|
|
297
|
+
parallel_tool_calls=params.get("parallel_tool_calls", None),
|
|
298
|
+
extra_query=params.get("extra_query", None),
|
|
299
|
+
extra_body=params.get("extra_body", None),
|
|
300
|
+
extra_headers=params.get("extra_headers", None),
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
@staticmethod
|
|
304
|
+
def convert_sp_input(
|
|
305
|
+
messages: list[ChatCompletionMessageParam],
|
|
306
|
+
) -> tuple[str | None, str | list[TResponseInputItem]]:
|
|
307
|
+
if isinstance(messages, str):
|
|
308
|
+
return None, messages
|
|
309
|
+
if messages[0].get("role", None) == "system":
|
|
310
|
+
return messages[0]["content"], messages[1:]
|
|
311
|
+
return None, messages
|
|
312
|
+
|
|
313
|
+
@staticmethod
|
|
314
|
+
def convert_tool(tool: ChatCompletionToolParam) -> FunctionTool:
|
|
315
|
+
assert tool["type"] == "function"
|
|
316
|
+
return FunctionTool(
|
|
317
|
+
name=tool["function"]["name"],
|
|
318
|
+
description=tool["function"].get("description", ""),
|
|
319
|
+
params_json_schema=tool["function"].get("parameters", None),
|
|
320
|
+
on_invoke_tool=None,
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
@staticmethod
|
|
324
|
+
def get_message_from_image(image_url: str) -> dict:
|
|
325
|
+
"""Get a message dict for image input."""
|
|
326
|
+
# from openai.types.responses.response_input_item_param import Message
|
|
327
|
+
# from openai.types.responses.response_input_image_param import ResponseInputImageParam
|
|
328
|
+
return {"role": "user", "content": [{"type": "input_image", "image_url": encode_image(image_url)}]}
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
class SimplifiedOpenAIChatCompletionsModel(OpenAIChatCompletionsModel):
|
|
332
|
+
"""extend OpenAIChatCompletionsModel to support basic api
|
|
333
|
+
- enable tracing based on SimplifiedAsyncOpenAI
|
|
334
|
+
"""
|
|
335
|
+
|
|
336
|
+
async def query_one(self, **kwargs) -> str:
|
|
337
|
+
system_instructions, input = AgentsUtils.convert_sp_input(kwargs["messages"])
|
|
338
|
+
model_settings = AgentsUtils.convert_model_settings(kwargs)
|
|
339
|
+
tools = [AgentsUtils.convert_tool(tool) for tool in kwargs.get("tools", [])]
|
|
340
|
+
response = await self.get_response(
|
|
341
|
+
system_instructions=system_instructions,
|
|
342
|
+
input=input,
|
|
343
|
+
model_settings=model_settings,
|
|
344
|
+
tools=tools,
|
|
345
|
+
output_schema=None,
|
|
346
|
+
handoffs=[],
|
|
347
|
+
tracing=ModelTracing.ENABLED,
|
|
348
|
+
previous_response_id=None,
|
|
349
|
+
prompt=None,
|
|
350
|
+
)
|
|
351
|
+
return ChatCompletionConverter.items_to_messages(response.to_input_items())
|
|
352
|
+
# with generation_span(
|
|
353
|
+
# model=kwargs["model"],
|
|
354
|
+
# model_config=_model_settings,
|
|
355
|
+
# input=_messages,
|
|
356
|
+
# ) as span_generation:
|
|
357
|
+
# result = await self.chat.completions.create(**kwargs)
|
|
358
|
+
# span_generation.span_data.output = result.choices[0].message.model_dump()
|
|
359
|
+
# return result
|
utils/common.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import importlib.util
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, Field
|
|
7
|
+
|
|
8
|
+
from .path import DIR_ROOT
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_event_loop() -> asyncio.AbstractEventLoop:
|
|
12
|
+
try:
|
|
13
|
+
loop = asyncio.get_event_loop()
|
|
14
|
+
except RuntimeError:
|
|
15
|
+
loop = asyncio.new_event_loop()
|
|
16
|
+
asyncio.set_event_loop(loop)
|
|
17
|
+
return loop
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def schema_to_basemodel(schema: dict, class_name: str = None) -> type[BaseModel]:
|
|
21
|
+
type_map = {
|
|
22
|
+
"string": str,
|
|
23
|
+
"integer": int,
|
|
24
|
+
"number": float,
|
|
25
|
+
"boolean": bool,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
def get_python_type(prop_schema):
|
|
29
|
+
prop_type = prop_schema.get("type")
|
|
30
|
+
|
|
31
|
+
if prop_type == "array":
|
|
32
|
+
item_type = prop_schema.get("items", {}).get("type", "string")
|
|
33
|
+
return list[type_map.get(item_type, str)]
|
|
34
|
+
|
|
35
|
+
return type_map.get(prop_type, str)
|
|
36
|
+
|
|
37
|
+
annotations = {}
|
|
38
|
+
fields = {}
|
|
39
|
+
properties = schema.get("properties", {})
|
|
40
|
+
required_fields = set(schema.get("required", []))
|
|
41
|
+
|
|
42
|
+
for field_name, field_schema in properties.items():
|
|
43
|
+
annotations[field_name] = get_python_type(field_schema)
|
|
44
|
+
field_kwargs = {}
|
|
45
|
+
if "description" in field_schema:
|
|
46
|
+
field_kwargs["description"] = field_schema["description"]
|
|
47
|
+
if field_name not in required_fields:
|
|
48
|
+
field_kwargs["default"] = None
|
|
49
|
+
annotations[field_name] = annotations[field_name] | None
|
|
50
|
+
if field_kwargs:
|
|
51
|
+
fields[field_name] = Field(**field_kwargs)
|
|
52
|
+
attrs = {
|
|
53
|
+
"__annotations__": annotations,
|
|
54
|
+
"__module__": __name__,
|
|
55
|
+
}
|
|
56
|
+
attrs.update(fields)
|
|
57
|
+
|
|
58
|
+
class_name = class_name or schema.get("title", "GeneratedModel")
|
|
59
|
+
ModelClass = type(class_name, (BaseModel,), attrs)
|
|
60
|
+
return ModelClass
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def load_class_from_file(filepath: str, class_name: str) -> type:
|
|
64
|
+
"""Load class from file."""
|
|
65
|
+
if not filepath.startswith("/"):
|
|
66
|
+
filepath = str(DIR_ROOT / filepath)
|
|
67
|
+
|
|
68
|
+
filepath = Path(filepath).absolute()
|
|
69
|
+
module_name = filepath.stem
|
|
70
|
+
spec = importlib.util.spec_from_file_location(module_name, filepath)
|
|
71
|
+
if spec is None:
|
|
72
|
+
raise ImportError(f"Could not load spec from file '{filepath}'")
|
|
73
|
+
|
|
74
|
+
module = importlib.util.module_from_spec(spec)
|
|
75
|
+
sys.modules[module_name] = module
|
|
76
|
+
|
|
77
|
+
spec.loader.exec_module(module)
|
|
78
|
+
|
|
79
|
+
if hasattr(module, class_name):
|
|
80
|
+
return getattr(module, class_name)
|
|
81
|
+
else:
|
|
82
|
+
raise AttributeError(f"Class '{class_name}' not found in module '{module_name}'")
|
utils/env.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import importlib.metadata
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
from dotenv import find_dotenv, load_dotenv
|
|
5
|
+
|
|
6
|
+
load_dotenv(find_dotenv(raise_error_if_not_found=True), verbose=True, override=True)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class EnvUtils:
|
|
10
|
+
@staticmethod
|
|
11
|
+
def get_env(key: str, default: str | None = None) -> str | None:
|
|
12
|
+
"""Get the value of an environment variable. Raise an error if default is not privided (None)"""
|
|
13
|
+
if default is None:
|
|
14
|
+
res = os.getenv(key)
|
|
15
|
+
if res is None:
|
|
16
|
+
raise ValueError(f"Environment variable {key} is not set")
|
|
17
|
+
return res
|
|
18
|
+
return os.getenv(key, default)
|
|
19
|
+
|
|
20
|
+
@staticmethod
|
|
21
|
+
def assert_env(key: str | list[str]) -> None:
|
|
22
|
+
if isinstance(key, list):
|
|
23
|
+
for k in key:
|
|
24
|
+
EnvUtils.assert_env(k)
|
|
25
|
+
else:
|
|
26
|
+
if not os.getenv(key):
|
|
27
|
+
raise ValueError(f"Environment variable {key} is not set")
|
|
28
|
+
|
|
29
|
+
@staticmethod
|
|
30
|
+
def ensure_package(package_name: str) -> None:
|
|
31
|
+
try:
|
|
32
|
+
importlib.metadata.version(package_name)
|
|
33
|
+
except importlib.metadata.PackageNotFoundError:
|
|
34
|
+
raise ValueError(f"Package {package_name} is required but not installed!") from None
|
utils/image.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import pathlib
|
|
3
|
+
|
|
4
|
+
import requests
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def encode_image(image_path: str) -> str:
|
|
8
|
+
"""Encode an image from a local path or URL to a base64 string."""
|
|
9
|
+
if pathlib.Path(image_path).is_file():
|
|
10
|
+
with open(image_path, "rb") as image_file:
|
|
11
|
+
return base64.b64encode(image_file.read()).decode("utf-8")
|
|
12
|
+
else:
|
|
13
|
+
response = requests.get(image_path)
|
|
14
|
+
response.raise_for_status()
|
|
15
|
+
image_base64 = base64.b64encode(response.content).decode("utf-8")
|
|
16
|
+
return f"data:image/jpeg;base64,{image_base64}"
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import re
|
|
3
|
+
|
|
4
|
+
LANGUAGE_TO_CANDS = {
|
|
5
|
+
"python": ["py", "python"],
|
|
6
|
+
"json": ["json"],
|
|
7
|
+
"javascript": ["js", "javascript"],
|
|
8
|
+
"yaml": ["yaml", "yml"],
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class LLMOutputParser:
|
|
13
|
+
@staticmethod
|
|
14
|
+
def extract_code_blocks(s: str) -> list[tuple[str, str]]:
|
|
15
|
+
"""Parse all code blocks (language, code) from the given string."""
|
|
16
|
+
pattern = re.compile(r"```(\w*)\n(.*?)```", re.DOTALL)
|
|
17
|
+
code_blocks = []
|
|
18
|
+
for match in pattern.finditer(s):
|
|
19
|
+
language = match.group(1).strip()
|
|
20
|
+
code = match.group(2).strip()
|
|
21
|
+
code_blocks.append((language, code))
|
|
22
|
+
return code_blocks
|
|
23
|
+
|
|
24
|
+
@staticmethod
|
|
25
|
+
def extract_code_block_with_language(s: str, language: str) -> str:
|
|
26
|
+
code_blocks = LLMOutputParser.extract_code_blocks(s)
|
|
27
|
+
if not code_blocks:
|
|
28
|
+
return s # fallback
|
|
29
|
+
for lang, code in code_blocks:
|
|
30
|
+
if lang.lower() in LANGUAGE_TO_CANDS[language]:
|
|
31
|
+
return code
|
|
32
|
+
return code_blocks[0][1] # fallback
|
|
33
|
+
|
|
34
|
+
@staticmethod
|
|
35
|
+
def extract_code_python(s: str) -> str:
|
|
36
|
+
"""Extract the first code block with the given language from the given string."""
|
|
37
|
+
return LLMOutputParser.extract_code_block_with_language(s, "python")
|
|
38
|
+
|
|
39
|
+
@staticmethod
|
|
40
|
+
def extract_code_json(s: str, try_parse: bool = True) -> str | dict:
|
|
41
|
+
"""Extract the first json code block from the given string."""
|
|
42
|
+
code = LLMOutputParser.extract_code_block_with_language(s, "json")
|
|
43
|
+
if try_parse:
|
|
44
|
+
try:
|
|
45
|
+
return json.loads(code)
|
|
46
|
+
except json.JSONDecodeError:
|
|
47
|
+
return code
|
|
48
|
+
return code
|
|
49
|
+
|
|
50
|
+
@staticmethod
|
|
51
|
+
def camel_to_snake(name: str) -> str:
|
|
52
|
+
"""Convert CamelCase / PascalCase to snake_case."""
|
|
53
|
+
s1 = re.sub(r"(.)([A-Z][a-z]+)", r"\1_\2", name)
|
|
54
|
+
s2 = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", s1)
|
|
55
|
+
return s2.lower()
|
|
56
|
+
|
|
57
|
+
@staticmethod
|
|
58
|
+
def snake_to_camel(name: str, pascal: bool = True) -> str:
|
|
59
|
+
"""Convert snake_case to CamelCase / PascalCase."""
|
|
60
|
+
components = name.split("_")
|
|
61
|
+
if pascal:
|
|
62
|
+
return "".join(x.title() for x in components)
|
|
63
|
+
else:
|
|
64
|
+
return components[0].lower() + "".join(x.title() for x in components[1:])
|
utils/log.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
import pathlib
|
|
4
|
+
from logging.handlers import TimedRotatingFileHandler
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
from colorlog import ColoredFormatter
|
|
8
|
+
|
|
9
|
+
DIR_LOGS = pathlib.Path(__file__).parent.parent.parent / "logs"
|
|
10
|
+
DIR_LOGS.mkdir(exist_ok=True)
|
|
11
|
+
|
|
12
|
+
# Flag to track if logging has been set up
|
|
13
|
+
_LOGGING_INITIALIZED = False
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def setup_logging(level: Literal["WARNING", "INFO", "DEBUG"] = "WARNING") -> None:
|
|
17
|
+
# Check if logging has already been initialized
|
|
18
|
+
global _LOGGING_INITIALIZED
|
|
19
|
+
if _LOGGING_INITIALIZED:
|
|
20
|
+
logging.getLogger().warning("Logging has already been initialized! Skipping...")
|
|
21
|
+
return
|
|
22
|
+
|
|
23
|
+
utu_logger = logging.getLogger("utu")
|
|
24
|
+
utu_logger.setLevel(logging.DEBUG)
|
|
25
|
+
if utu_logger.handlers:
|
|
26
|
+
utu_logger.handlers.clear()
|
|
27
|
+
|
|
28
|
+
console_handler = logging.StreamHandler()
|
|
29
|
+
console_handler.setLevel(level)
|
|
30
|
+
color_formatter = ColoredFormatter(
|
|
31
|
+
"%(green)s%(asctime)s%(reset)s[%(blue)s%(name)s%(reset)s] - "
|
|
32
|
+
"%(log_color)s%(levelname)s%(reset)s - %(filename)s:%(lineno)d - %(green)s%(message)s%(reset)s",
|
|
33
|
+
# " - %(cyan)s%(threadName)s%(reset)s",
|
|
34
|
+
log_colors={
|
|
35
|
+
"DEBUG": "cyan",
|
|
36
|
+
"INFO": "white",
|
|
37
|
+
"WARNING": "yellow",
|
|
38
|
+
"ERROR": "red",
|
|
39
|
+
"CRITICAL": "red,bg_white",
|
|
40
|
+
},
|
|
41
|
+
secondary_log_colors={"asctime": {"green": "green"}, "name": {"blue": "blue"}},
|
|
42
|
+
)
|
|
43
|
+
console_handler.setFormatter(color_formatter)
|
|
44
|
+
|
|
45
|
+
file_handler = TimedRotatingFileHandler(
|
|
46
|
+
DIR_LOGS / "utu.log", when="midnight", interval=1, backupCount=30, encoding="utf-8"
|
|
47
|
+
)
|
|
48
|
+
file_handler.setLevel(logging.DEBUG)
|
|
49
|
+
formatter = logging.Formatter(
|
|
50
|
+
"%(asctime)s[%(name)s] - %(levelname)s - %(filename)s:%(lineno)d - %(message)s - %(threadName)s"
|
|
51
|
+
)
|
|
52
|
+
file_handler.setFormatter(formatter)
|
|
53
|
+
|
|
54
|
+
utu_logger.addHandler(console_handler)
|
|
55
|
+
utu_logger.addHandler(file_handler)
|
|
56
|
+
utu_logger.info(f"Logging initialized with level {level}.")
|
|
57
|
+
|
|
58
|
+
# Mark logging as initialized
|
|
59
|
+
_LOGGING_INITIALIZED = True
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def get_logger(name: str, level: int | str = logging.DEBUG) -> logging.Logger:
|
|
63
|
+
logger = logging.getLogger(name)
|
|
64
|
+
logger.setLevel(level)
|
|
65
|
+
|
|
66
|
+
def log_error_with_exc(msg, *args, **kwargs):
|
|
67
|
+
kwargs["exc_info"] = True
|
|
68
|
+
logger.error(msg, *args, **kwargs)
|
|
69
|
+
|
|
70
|
+
logger.error_exc = log_error_with_exc
|
|
71
|
+
return logger
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def oneline_object(obj: object, limit: int = 100) -> str:
|
|
75
|
+
try:
|
|
76
|
+
s = json.dumps(obj, ensure_ascii=False)
|
|
77
|
+
except TypeError:
|
|
78
|
+
s = json.dumps(str(obj), ensure_ascii=False)
|
|
79
|
+
return f"{s[:limit]}..." if len(s) > limit else s
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
from .openai_utils import OpenAIUtils
|
|
2
|
+
from .simplified_client import SimplifiedAsyncOpenAI
|
|
3
|
+
from .types import OpenAIChatCompletionParams, OpenAIResponsesParams
|
|
4
|
+
|
|
5
|
+
__all__ = ["OpenAIUtils", "SimplifiedAsyncOpenAI", "OpenAIChatCompletionParams", "OpenAIResponsesParams"]
|