diff --git a/aidial_assistant/application/assistant_application.py b/aidial_assistant/application/assistant_application.py index 7922047..258d151 100644 --- a/aidial_assistant/application/assistant_application.py +++ b/aidial_assistant/application/assistant_application.py @@ -1,13 +1,11 @@ import logging from pathlib import Path -from aidial_sdk import HTTPException from aidial_sdk.chat_completion import FinishReason from aidial_sdk.chat_completion.base import ChatCompletion -from aidial_sdk.chat_completion.request import Addon, Request +from aidial_sdk.chat_completion.request import Addon, Message, Request, Role from aidial_sdk.chat_completion.response import Response from aiohttp import hdrs -from openai import InvalidRequestError, OpenAIError from aidial_assistant.application.args import parse_args from aidial_assistant.application.assistant_callback import ( @@ -22,21 +20,24 @@ from aidial_assistant.chain.model_client import ( ModelClient, ReasonLengthException, - UsagePublisher, ) from aidial_assistant.commands.reply import Reply from aidial_assistant.commands.run_plugin import PluginInfo, RunPlugin +from aidial_assistant.utils.exceptions import ( + RequestParameterValidationError, + unhandled_exception_handler, +) from aidial_assistant.utils.open_ai_plugin import ( AddonTokenSource, get_open_ai_plugin_info, get_plugin_auth, ) -from aidial_assistant.utils.state import parse_history +from aidial_assistant.utils.state import State, parse_history logger = logging.getLogger(__name__) -def get_request_args(request: Request) -> dict[str, str]: +def _get_request_args(request: Request) -> dict[str, str]: args = { "model": request.model, "temperature": request.temperature, @@ -51,21 +52,43 @@ def get_request_args(request: Request) -> dict[str, str]: return {k: v for k, v in args.items() if v is not None} -def _extract_addon_url(addon: Addon) -> str: - if addon.url is None: - raise InvalidRequestError("Missing required addon url.", param="") +def _validate_addons(addons: list[Addon] | None): + if addons and any(addon.url is None for addon in addons): + for index, addon in enumerate(addons): + if addon.url is None: + raise RequestParameterValidationError( + f"Missing required addon url at index {index}.", + param="addons", + ) + + +def _validate_messages(messages: list[Message]) -> None: + if not messages: + raise RequestParameterValidationError( + "Message list cannot be empty.", param="messages" + ) + + if messages[-1].role != Role.USER: + raise RequestParameterValidationError( + "Last message must be from the user.", param="messages" + ) + - return addon.url +def _validate_request(request: Request) -> None: + _validate_messages(request.messages) + _validate_addons(request.addons) class AssistantApplication(ChatCompletion): def __init__(self, config_dir: Path): self.args = parse_args(config_dir) + @unhandled_exception_handler async def chat_completion( self, request: Request, response: Response ) -> None: - chat_args = self.args.openai_conf.dict() | get_request_args(request) + _validate_request(request) + chat_args = self.args.openai_conf.dict() | _get_request_args(request) model = ModelClient( model_args=chat_args @@ -77,10 +100,8 @@ async def chat_completion( buffer_size=self.args.chat_conf.buffer_size, ) - addons = ( - [_extract_addon_url(addon) for addon in request.addons] - if request.addons - else [] + addons: list[str] = ( + [addon.url for addon in request.addons] if request.addons else [] # type: ignore ) token_source = AddonTokenSource(request.headers, addons) @@ -103,16 +124,12 @@ async def chat_completion( or info.ai_plugin.description_for_human ) - usage_publisher = UsagePublisher() command_dict: CommandDict = { - RunPlugin.token(): lambda: RunPlugin(model, tools, usage_publisher), + RunPlugin.token(): lambda: RunPlugin(model, tools), Reply.token(): Reply, } chain = CommandChain( - model_client=model, - name="ASSISTANT", - command_dict=command_dict, - usage_publisher=usage_publisher, + model_client=model, name="ASSISTANT", command_dict=command_dict ) history = History( assistant_system_message_template=MAIN_SYSTEM_DIALOG_MESSAGE.build( @@ -123,6 +140,14 @@ async def chat_completion( ), scoped_messages=parse_history(request.messages), ) + discarded_messages: int | None = None + if request.max_prompt_tokens is not None: + original_size = history.user_message_count + history = await history.truncate(request.max_prompt_tokens, model) + truncated_size = history.user_message_count + discarded_messages = original_size - truncated_size + # TODO: else compare the history size to the max prompt tokens of the underlying model + choice = response.create_single_choice() choice.open() @@ -132,19 +157,13 @@ async def chat_completion( await chain.run_chat(history, callback) except ReasonLengthException: finish_reason = FinishReason.LENGTH - except OpenAIError as e: - if e.error: - raise HTTPException( - e.error.message, - status_code=e.http_status or 500, - code=e.error.code, - ) - raise + if callback.invocations: + choice.set_state(State(invocations=callback.invocations)) - choice.set_state(callback.get_state()) choice.close(finish_reason) - response.set_usage( - usage_publisher.prompt_tokens, usage_publisher.completion_tokens - ) + response.set_usage(model.prompt_tokens, model.completion_tokens) + + if discarded_messages is not None: + response.set_discarded_messages(discarded_messages) diff --git a/aidial_assistant/application/assistant_callback.py b/aidial_assistant/application/assistant_callback.py index 4292326..4fbcb8d 100644 --- a/aidial_assistant/application/assistant_callback.py +++ b/aidial_assistant/application/assistant_callback.py @@ -13,7 +13,7 @@ from aidial_assistant.chain.callbacks.result_callback import ResultCallback from aidial_assistant.commands.base import ExecutionCallback, ResultObject from aidial_assistant.commands.run_plugin import RunPlugin -from aidial_assistant.utils.state import Invocation, State +from aidial_assistant.utils.state import Invocation class PluginNameArgCallback(ArgCallback): @@ -113,6 +113,7 @@ def __init__(self, choice: Choice): self.choice = choice self._invocations: list[Invocation] = [] self._invocation_index: int = -1 + self._discarded_messages: int = 0 @override def command_callback(self) -> CommandCallback: @@ -138,5 +139,6 @@ def on_error(self, title: str, error: str): stage.append_content(f"Error: {error}\n") stage.close(Status.FAILED) - def get_state(self): - return State(invocations=self._invocations) + @property + def invocations(self) -> list[Invocation]: + return self._invocations diff --git a/aidial_assistant/chain/command_chain.py b/aidial_assistant/chain/command_chain.py index 10041f8..633bdf2 100644 --- a/aidial_assistant/chain/command_chain.py +++ b/aidial_assistant/chain/command_chain.py @@ -18,11 +18,7 @@ ) from aidial_assistant.chain.dialogue import Dialogue from aidial_assistant.chain.history import History -from aidial_assistant.chain.model_client import ( - Message, - ModelClient, - UsagePublisher, -) +from aidial_assistant.chain.model_client import Message, ModelClient from aidial_assistant.chain.model_response_reader import ( AssistantProtocolException, CommandsReader, @@ -48,23 +44,17 @@ CommandDict = dict[str, CommandConstructor] -class MaxRetryCountExceededException(Exception): - pass - - class CommandChain: def __init__( self, name: str, model_client: ModelClient, command_dict: CommandDict, - usage_publisher: UsagePublisher, max_retry_count: int = DEFAULT_MAX_RETRY_COUNT, ): self.name = name self.model_client = model_client self.command_dict = command_dict - self.usage_publisher = usage_publisher self.max_retry_count = max_retry_count def _log_message(self, role: Role, content: str): @@ -81,8 +71,7 @@ async def run_chat(self, history: History, callback: ChainCallback): messages = history.to_protocol_messages() while True: pair = await self._run_with_protocol_failure_retries( - callback, - self._reinforce_json_format(messages + dialogue.messages), + callback, messages + dialogue.messages ) if pair is None: @@ -96,7 +85,7 @@ async def run_chat(self, history: History, callback: ChainCallback): dialogue, ) if not dialogue.is_empty() - else history.to_client_messages() + else history.to_user_messages() ) await self._generate_result(messages, callback) except InvalidRequestError as e: @@ -112,15 +101,14 @@ async def run_chat(self, history: History, callback: ChainCallback): async def _run_with_protocol_failure_retries( self, callback: ChainCallback, messages: list[Message] ) -> Tuple[str, str] | None: - self._log_messages(messages) last_error: Exception | None = None try: - retry: int = 0 + self._log_messages(messages) retries = Dialogue() while True: chunk_stream = CumulativeStream( self.model_client.agenerate( - messages + retries.messages, self.usage_publisher + self._reinforce_json_format(messages + retries.messages) ) ) try: @@ -139,15 +127,17 @@ async def _run_with_protocol_failure_retries( except (JsonParsingException, AssistantProtocolException) as e: logger.exception("Failed to process model response") + retry_count = len(retries.messages) // 2 callback.on_error( - "Error" if retry == 0 else f"Error (retry {retry})", + "Error" + if retry_count == 0 + else f"Error (retry {retry_count})", "The model failed to construct addon request.", ) - if retry >= self.max_retry_count: + if retry_count >= self.max_retry_count: raise - retry += 1 last_error = e retries.append( chunk_stream.buffer, @@ -157,6 +147,8 @@ async def _run_with_protocol_failure_retries( self._log_message(Role.ASSISTANT, chunk_stream.buffer) except InvalidRequestError as e: if last_error: + # Retries can increase the prompt size, which may lead to token overflow. + # Thus, if the original error was a protocol error, it should be thrown instead. raise last_error callback.on_error("Error", str(e)) @@ -211,7 +203,7 @@ def _create_command(self, name: str) -> Command: async def _generate_result( self, messages: list[Message], callback: ChainCallback ): - stream = self.model_client.agenerate(messages, self.usage_publisher) + stream = self.model_client.agenerate(messages) await CommandChain._to_result(stream, callback.result_callback()) diff --git a/aidial_assistant/chain/history.py b/aidial_assistant/chain/history.py index e7040db..61d4ded 100644 --- a/aidial_assistant/chain/history.py +++ b/aidial_assistant/chain/history.py @@ -3,16 +3,26 @@ from aidial_sdk.chat_completion import Role from jinja2 import Template from pydantic import BaseModel +from typing_extensions import override from aidial_assistant.chain.command_result import ( CommandInvocation, commands_to_text, ) from aidial_assistant.chain.dialogue import Dialogue -from aidial_assistant.chain.model_client import Message +from aidial_assistant.chain.model_client import ( + ExtraResultsCallback, + Message, + ModelClient, + ReasonLengthException, +) from aidial_assistant.commands.reply import Reply +class ContextLengthExceeded(Exception): + pass + + class MessageScope(str, Enum): INTERNAL = "internal" # internal dialog with plugins/addons, not visible to the user on the top level USER = "user" # top-level dialog with the user @@ -23,6 +33,19 @@ class ScopedMessage(BaseModel): message: Message +class ModelExtraResultsCallback(ExtraResultsCallback): + def __init__(self): + self._discarded_messages: int | None = None + + @override + def on_discarded_messages(self, discarded_messages: int): + self._discarded_messages = discarded_messages + + @property + def discarded_messages(self) -> int | None: + return self._discarded_messages + + class History: def __init__( self, @@ -35,26 +58,34 @@ def __init__( ) self.best_effort_template = best_effort_template self.scoped_messages = scoped_messages + self._user_message_count = sum( + 1 + for message in scoped_messages + if message.scope == MessageScope.USER + ) def to_protocol_messages(self) -> list[Message]: messages: list[Message] = [] for index, scoped_message in enumerate(self.scoped_messages): - scope = scoped_message.scope message = scoped_message.message + scope = scoped_message.scope + if index == 0: - messages.append( - Message.system( - self.assistant_system_message_template.render( - system_prefix=message.content - if message.role == Role.SYSTEM - else "" + if message.role == Role.SYSTEM: + messages.append( + Message.system( + self.assistant_system_message_template.render( + system_prefix=message.content + ) + ) + ) + else: + messages.append( + Message.system( + self.assistant_system_message_template.render() ) ) - ) - - if message.role != Role.SYSTEM: messages.append(message) - elif scope == MessageScope.USER and message.role == Role.ASSISTANT: # Clients see replies in plain text, but the model should understand how to reply appropriately. content = commands_to_text( @@ -70,7 +101,7 @@ def to_protocol_messages(self) -> list[Message]: return messages - def to_client_messages(self) -> list[Message]: + def to_user_messages(self) -> list[Message]: return [ scoped_message.message for scoped_message in self.scoped_messages @@ -80,7 +111,7 @@ def to_client_messages(self) -> list[Message]: def to_best_effort_messages( self, error: str, dialogue: Dialogue ) -> list[Message]: - messages = self.to_client_messages() + messages = self.to_user_messages() last_message = messages[-1] messages[-1] = Message( @@ -93,3 +124,65 @@ def to_best_effort_messages( ) return messages + + async def truncate( + self, max_prompt_tokens: int, model_client: ModelClient + ) -> "History": + extra_results_callback = ModelExtraResultsCallback() + # TODO: This will be replaced with a dedicated truncation call on model client once implemented. + stream = model_client.agenerate( + self.to_protocol_messages(), + extra_results_callback, + max_prompt_tokens=max_prompt_tokens, + max_tokens=1, + ) + try: + async for _ in stream: + pass + except ReasonLengthException: + # Expected for max_tokens=1 + pass + + if extra_results_callback.discarded_messages: + return History( + assistant_system_message_template=self.assistant_system_message_template, + best_effort_template=self.best_effort_template, + scoped_messages=self._skip_messages( + extra_results_callback.discarded_messages + ), + ) + + return self + + @property + def user_message_count(self) -> int: + return self._user_message_count + + def _skip_messages(self, discarded_messages: int) -> list[ScopedMessage]: + messages: list[ScopedMessage] = [] + current_message = self.scoped_messages[0] + message_iterator = iter(self.scoped_messages) + for _ in range(discarded_messages): + current_message = next(message_iterator) + while current_message.message.role == Role.SYSTEM: + # System messages should be kept in the history + messages.append(current_message) + current_message = next(message_iterator) + + if current_message.scope == MessageScope.INTERNAL: + while current_message.scope == MessageScope.INTERNAL: + current_message = next(message_iterator) + + # Internal messages (i.e. addon requests/responses) are always followed by an assistant reply + assert ( + current_message.message.role == Role.ASSISTANT + ), "Internal messages must be followed by an assistant reply." + + remaining_messages = list(message_iterator) + assert ( + len(remaining_messages) > 0 + ), "No user messages left after history truncation." + + messages += remaining_messages + + return messages diff --git a/aidial_assistant/chain/model_client.py b/aidial_assistant/chain/model_client.py index a31ee3f..8ce0130 100644 --- a/aidial_assistant/chain/model_client.py +++ b/aidial_assistant/chain/model_client.py @@ -1,6 +1,5 @@ -from abc import ABC -from collections import defaultdict -from typing import Any, AsyncIterator, List +from abc import ABC, abstractmethod +from typing import Any, AsyncIterator, List, TypedDict import openai from aidial_sdk.chat_completion import Role @@ -32,21 +31,15 @@ def assistant(cls, content): return cls(role=Role.ASSISTANT, content=content) -class UsagePublisher: - def __init__(self): - self.total_usage = defaultdict(int) +class Usage(TypedDict): + prompt_tokens: int + completion_tokens: int - def publish(self, usage: dict[str, int]): - for k, v in usage.items(): - self.total_usage[k] += v - @property - def prompt_tokens(self) -> int: - return self.total_usage["prompt_tokens"] - - @property - def completion_tokens(self) -> int: - return self.total_usage["completion_tokens"] +class ExtraResultsCallback(ABC): + @abstractmethod + def on_discarded_messages(self, discarded_messages: int): + pass class ModelClient(ABC): @@ -58,21 +51,38 @@ def __init__( self.model_args = model_args self.buffer_size = buffer_size + self._prompt_tokens: int = 0 + self._completion_tokens: int = 0 + async def agenerate( - self, messages: List[Message], usage_publisher: UsagePublisher + self, + messages: List[Message], + extra_results_callback: ExtraResultsCallback | None = None, + **kwargs, ) -> AsyncIterator[str]: async with ClientSession(read_bufsize=self.buffer_size) as session: openai.aiosession.set(session) model_result = await openai.ChatCompletion.acreate( - **self.model_args, - messages=[message.to_openai_message() for message in messages] + messages=[message.to_openai_message() for message in messages], + **self.model_args | kwargs, ) + finish_reason_length = False async for chunk in model_result: # type: ignore - usage = chunk.get("usage") + usage: Usage | None = chunk.get("usage") if usage: - usage_publisher.publish(usage) + self._prompt_tokens += usage["prompt_tokens"] + self._completion_tokens += usage["completion_tokens"] + + if extra_results_callback: + discarded_messages: int | None = chunk.get( + "statistics", {} + ).get("discarded_messages") + if discarded_messages is not None: + extra_results_callback.on_discarded_messages( + discarded_messages + ) choice = chunk["choices"][0] text = choice["delta"].get("content") @@ -80,4 +90,15 @@ async def agenerate( yield text if choice.get("finish_reason") == "length": - raise ReasonLengthException() + finish_reason_length = True + + if finish_reason_length: + raise ReasonLengthException() + + @property + def prompt_tokens(self) -> int: + return self._prompt_tokens + + @property + def completion_tokens(self) -> int: + return self._completion_tokens diff --git a/aidial_assistant/commands/run_plugin.py b/aidial_assistant/commands/run_plugin.py index 08fe0e2..01cc732 100644 --- a/aidial_assistant/commands/run_plugin.py +++ b/aidial_assistant/commands/run_plugin.py @@ -17,7 +17,6 @@ Message, ModelClient, ReasonLengthException, - UsagePublisher, ) from aidial_assistant.commands.base import ( Command, @@ -39,14 +38,10 @@ class PluginInfo(BaseModel): class RunPlugin(Command): def __init__( - self, - model_client: ModelClient, - plugins: dict[str, PluginInfo], - usage_publisher: UsagePublisher, + self, model_client: ModelClient, plugins: dict[str, PluginInfo] ): self.model_client = model_client self.plugins = plugins - self.usage_publisher = usage_publisher @staticmethod def token(): @@ -60,21 +55,10 @@ async def execute( name = args[0] query = args[1] - return await self._run_plugin( - name, - query, - self.model_client, - self.usage_publisher, - execution_callback, - ) + return await self._run_plugin(name, query, execution_callback) async def _run_plugin( - self, - name: str, - query: str, - model_client: ModelClient, - usage_publisher: UsagePublisher, - execution_callback: ExecutionCallback, + self, name: str, query: str, execution_callback: ExecutionCallback ) -> ResultObject: if name not in self.plugins: raise ValueError( @@ -110,10 +94,9 @@ def create_command(op: APIOperation): ) chat = CommandChain( - model_client=model_client, + model_client=self.model_client, name="PLUGIN:" + name, command_dict=command_dict, - usage_publisher=usage_publisher, ) callback = PluginChainCallback(execution_callback) diff --git a/aidial_assistant/utils/exceptions.py b/aidial_assistant/utils/exceptions.py new file mode 100644 index 0000000..bb03218 --- /dev/null +++ b/aidial_assistant/utils/exceptions.py @@ -0,0 +1,56 @@ +import logging +from functools import wraps + +from aidial_sdk import HTTPException +from openai import OpenAIError + +logger = logging.getLogger(__name__) + + +class RequestParameterValidationError(Exception): + def __init__(self, message: str, param: str, *args: object) -> None: + super().__init__(message, *args) + self._param = param + + @property + def param(self) -> str: + return self._param + + +def _to_http_exception(e: Exception) -> HTTPException: + if isinstance(e, RequestParameterValidationError): + return HTTPException( + message=str(e), + status_code=422, + type="invalid_request_error", + param=e.param, + ) + + if isinstance(e, OpenAIError): + http_status = e.http_status or 500 + if e.error: + return HTTPException( + message=e.error.message, + status_code=http_status, + type=e.error.type, + code=e.error.code, + param=e.error.param, + ) + + return HTTPException(message=str(e), status_code=http_status) + + return HTTPException( + message=str(e), status_code=500, type="internal_server_error" + ) + + +def unhandled_exception_handler(func): + @wraps(func) + async def wrapper(*args, **kwargs): + try: + return await func(*args, **kwargs) + except Exception as e: + logger.exception("Unhandled exception") + raise _to_http_exception(e) + + return wrapper diff --git a/poetry.lock b/poetry.lock index 0d7f901..3819072 100644 --- a/poetry.lock +++ b/poetry.lock @@ -40,87 +40,87 @@ redis = ["redis (>=4.2.0)"] [[package]] name = "aiohttp" -version = "3.9.0" +version = "3.9.1" description = "Async http client/server framework (asyncio)" optional = false python-versions = ">=3.8" files = [ - {file = "aiohttp-3.9.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:6896b8416be9ada4d22cd359d7cb98955576ce863eadad5596b7cdfbf3e17c6c"}, - {file = "aiohttp-3.9.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1736d87dad8ef46a8ec9cddd349fa9f7bd3a064c47dd6469c0d6763d3d49a4fc"}, - {file = "aiohttp-3.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8c9e5f4d7208cda1a2bb600e29069eecf857e6980d0ccc922ccf9d1372c16f4b"}, - {file = "aiohttp-3.9.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8488519aa05e636c5997719fe543c8daf19f538f4fa044f3ce94bee608817cff"}, - {file = "aiohttp-3.9.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5ab16c254e2312efeb799bc3c06897f65a133b38b69682bf75d1f1ee1a9c43a9"}, - {file = "aiohttp-3.9.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7a94bde005a8f926d0fa38b88092a03dea4b4875a61fbcd9ac6f4351df1b57cd"}, - {file = "aiohttp-3.9.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b777c9286b6c6a94f50ddb3a6e730deec327e9e2256cb08b5530db0f7d40fd8"}, - {file = "aiohttp-3.9.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:571760ad7736b34d05597a1fd38cbc7d47f7b65deb722cb8e86fd827404d1f6b"}, - {file = "aiohttp-3.9.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:deac0a32aec29608eb25d730f4bc5a261a65b6c48ded1ed861d2a1852577c932"}, - {file = "aiohttp-3.9.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:4ee1b4152bc3190cc40ddd6a14715e3004944263ea208229ab4c297712aa3075"}, - {file = "aiohttp-3.9.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:3607375053df58ed6f23903aa10cf3112b1240e8c799d243bbad0f7be0666986"}, - {file = "aiohttp-3.9.0-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:65b0a70a25456d329a5e1426702dde67be0fb7a4ead718005ba2ca582d023a94"}, - {file = "aiohttp-3.9.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:5a2eb5311a37fe105aa35f62f75a078537e1a9e4e1d78c86ec9893a3c97d7a30"}, - {file = "aiohttp-3.9.0-cp310-cp310-win32.whl", hash = "sha256:2cbc14a13fb6b42d344e4f27746a4b03a2cb0c1c3c5b932b0d6ad8881aa390e3"}, - {file = "aiohttp-3.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:ac9669990e2016d644ba8ae4758688534aabde8dbbc81f9af129c3f5f01ca9cd"}, - {file = "aiohttp-3.9.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f8e05f5163528962ce1d1806fce763ab893b1c5b7ace0a3538cd81a90622f844"}, - {file = "aiohttp-3.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4afa8f71dba3a5a2e1e1282a51cba7341ae76585345c43d8f0e624882b622218"}, - {file = "aiohttp-3.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f929f4c9b9a00f3e6cc0587abb95ab9c05681f8b14e0fe1daecfa83ea90f8318"}, - {file = "aiohttp-3.9.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28185e36a78d247c55e9fbea2332d16aefa14c5276a582ce7a896231c6b1c208"}, - {file = "aiohttp-3.9.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a486ddf57ab98b6d19ad36458b9f09e6022de0381674fe00228ca7b741aacb2f"}, - {file = "aiohttp-3.9.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:70e851f596c00f40a2f00a46126c95c2e04e146015af05a9da3e4867cfc55911"}, - {file = "aiohttp-3.9.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c5b7bf8fe4d39886adc34311a233a2e01bc10eb4e842220235ed1de57541a896"}, - {file = "aiohttp-3.9.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c67a51ea415192c2e53e4e048c78bab82d21955b4281d297f517707dc836bf3d"}, - {file = "aiohttp-3.9.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:694df243f394629bcae2d8ed94c589a181e8ba8604159e6e45e7b22e58291113"}, - {file = "aiohttp-3.9.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:3dd8119752dd30dd7bca7d4bc2a92a59be6a003e4e5c2cf7e248b89751b8f4b7"}, - {file = "aiohttp-3.9.0-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:eb6dfd52063186ac97b4caa25764cdbcdb4b10d97f5c5f66b0fa95052e744eb7"}, - {file = "aiohttp-3.9.0-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:d97c3e286d0ac9af6223bc132dc4bad6540b37c8d6c0a15fe1e70fb34f9ec411"}, - {file = "aiohttp-3.9.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:816f4db40555026e4cdda604a1088577c1fb957d02f3f1292e0221353403f192"}, - {file = "aiohttp-3.9.0-cp311-cp311-win32.whl", hash = "sha256:3abf0551874fecf95f93b58f25ef4fc9a250669a2257753f38f8f592db85ddea"}, - {file = "aiohttp-3.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:e18d92c3e9e22553a73e33784fcb0ed484c9874e9a3e96c16a8d6a1e74a0217b"}, - {file = "aiohttp-3.9.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:99ae01fb13a618b9942376df77a1f50c20a281390dad3c56a6ec2942e266220d"}, - {file = "aiohttp-3.9.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:05857848da443c8c12110d99285d499b4e84d59918a21132e45c3f0804876994"}, - {file = "aiohttp-3.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:317719d7f824eba55857fe0729363af58e27c066c731bc62cd97bc9c3d9c7ea4"}, - {file = "aiohttp-3.9.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1e3b3c107ccb0e537f309f719994a55621acd2c8fdf6d5ce5152aed788fb940"}, - {file = "aiohttp-3.9.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:45820ddbb276113ead8d4907a7802adb77548087ff5465d5c554f9aa3928ae7d"}, - {file = "aiohttp-3.9.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:05a183f1978802588711aed0dea31e697d760ce9055292db9dc1604daa9a8ded"}, - {file = "aiohttp-3.9.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a4cd44788ea0b5e6bb8fa704597af3a30be75503a7ed1098bc5b8ffdf6c982"}, - {file = "aiohttp-3.9.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:673343fbc0c1ac44d0d2640addc56e97a052504beacd7ade0dc5e76d3a4c16e8"}, - {file = "aiohttp-3.9.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7e8a3b79b6d186a9c99761fd4a5e8dd575a48d96021f220ac5b5fa856e5dd029"}, - {file = "aiohttp-3.9.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:6777a390e41e78e7c45dab43a4a0196c55c3b8c30eebe017b152939372a83253"}, - {file = "aiohttp-3.9.0-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:7ae5f99a32c53731c93ac3075abd3e1e5cfbe72fc3eaac4c27c9dd64ba3b19fe"}, - {file = "aiohttp-3.9.0-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:f1e4f254e9c35d8965d377e065c4a8a55d396fe87c8e7e8429bcfdeeb229bfb3"}, - {file = "aiohttp-3.9.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:11ca808f9a6b63485059f5f6e164ef7ec826483c1212a44f268b3653c91237d8"}, - {file = "aiohttp-3.9.0-cp312-cp312-win32.whl", hash = "sha256:de3cc86f4ea8b4c34a6e43a7306c40c1275e52bfa9748d869c6b7d54aa6dad80"}, - {file = "aiohttp-3.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:ca4fddf84ac7d8a7d0866664936f93318ff01ee33e32381a115b19fb5a4d1202"}, - {file = "aiohttp-3.9.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:f09960b5bb1017d16c0f9e9f7fc42160a5a49fa1e87a175fd4a2b1a1833ea0af"}, - {file = "aiohttp-3.9.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8303531e2c17b1a494ffaeba48f2da655fe932c4e9a2626c8718403c83e5dd2b"}, - {file = "aiohttp-3.9.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4790e44f46a4aa07b64504089def5744d3b6780468c4ec3a1a36eb7f2cae9814"}, - {file = "aiohttp-3.9.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1d7edf74a36de0e5ca50787e83a77cf352f5504eb0ffa3f07000a911ba353fb"}, - {file = "aiohttp-3.9.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:94697c7293199c2a2551e3e3e18438b4cba293e79c6bc2319f5fd652fccb7456"}, - {file = "aiohttp-3.9.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a1b66dbb8a7d5f50e9e2ea3804b01e766308331d0cac76eb30c563ac89c95985"}, - {file = "aiohttp-3.9.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9623cfd9e85b76b83ef88519d98326d4731f8d71869867e47a0b979ffec61c73"}, - {file = "aiohttp-3.9.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f32c86dc967ab8c719fd229ce71917caad13cc1e8356ee997bf02c5b368799bf"}, - {file = "aiohttp-3.9.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:f50b4663c3e0262c3a361faf440761fbef60ccdde5fe8545689a4b3a3c149fb4"}, - {file = "aiohttp-3.9.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:dcf71c55ec853826cd70eadb2b6ac62ec577416442ca1e0a97ad875a1b3a0305"}, - {file = "aiohttp-3.9.0-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:42fe4fd9f0dfcc7be4248c162d8056f1d51a04c60e53366b0098d1267c4c9da8"}, - {file = "aiohttp-3.9.0-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:76a86a9989ebf82ee61e06e2bab408aec4ea367dc6da35145c3352b60a112d11"}, - {file = "aiohttp-3.9.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:f9e09a1c83521d770d170b3801eea19b89f41ccaa61d53026ed111cb6f088887"}, - {file = "aiohttp-3.9.0-cp38-cp38-win32.whl", hash = "sha256:a00ce44c21612d185c5275c5cba4bab8d7c1590f248638b667ed8a782fa8cd6f"}, - {file = "aiohttp-3.9.0-cp38-cp38-win_amd64.whl", hash = "sha256:d5b9345ab92ebe6003ae11d8092ce822a0242146e6fa270889b9ba965457ca40"}, - {file = "aiohttp-3.9.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:98d21092bf2637c5fa724a428a69e8f5955f2182bff61f8036827cf6ce1157bf"}, - {file = "aiohttp-3.9.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:35a68cd63ca6aaef5707888f17a70c36efe62b099a4e853d33dc2e9872125be8"}, - {file = "aiohttp-3.9.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3d7f6235c7475658acfc1769d968e07ab585c79f6ca438ddfecaa9a08006aee2"}, - {file = "aiohttp-3.9.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:db04d1de548f7a62d1dd7e7cdf7c22893ee168e22701895067a28a8ed51b3735"}, - {file = "aiohttp-3.9.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:536b01513d67d10baf6f71c72decdf492fb7433c5f2f133e9a9087379d4b6f31"}, - {file = "aiohttp-3.9.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:87c8b0a6487e8109427ccf638580865b54e2e3db4a6e0e11c02639231b41fc0f"}, - {file = "aiohttp-3.9.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7276fe0017664414fdc3618fca411630405f1aaf0cc3be69def650eb50441787"}, - {file = "aiohttp-3.9.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:23170247ef89ffa842a02bbfdc425028574d9e010611659abeb24d890bc53bb8"}, - {file = "aiohttp-3.9.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b1a2ea8252cacc7fd51df5a56d7a2bb1986ed39be9397b51a08015727dfb69bd"}, - {file = "aiohttp-3.9.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:2d71abc15ff7047412ef26bf812dfc8d0d1020d664617f4913df2df469f26b76"}, - {file = "aiohttp-3.9.0-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:2d820162c8c2bdbe97d328cd4f417c955ca370027dce593345e437b2e9ffdc4d"}, - {file = "aiohttp-3.9.0-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:2779f5e7c70f7b421915fd47db332c81de365678180a9f3ab404088f87ba5ff9"}, - {file = "aiohttp-3.9.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:366bc870d7ac61726f32a489fbe3d1d8876e87506870be66b01aeb84389e967e"}, - {file = "aiohttp-3.9.0-cp39-cp39-win32.whl", hash = "sha256:1df43596b826022b14998f0460926ce261544fedefe0d2f653e1b20f49e96454"}, - {file = "aiohttp-3.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:9c196b30f1b1aa3363a69dd69079ae9bec96c2965c4707eaa6914ba099fb7d4f"}, - {file = "aiohttp-3.9.0.tar.gz", hash = "sha256:09f23292d29135025e19e8ff4f0a68df078fe4ee013bca0105b2e803989de92d"}, + {file = "aiohttp-3.9.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e1f80197f8b0b846a8d5cf7b7ec6084493950d0882cc5537fb7b96a69e3c8590"}, + {file = "aiohttp-3.9.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c72444d17777865734aa1a4d167794c34b63e5883abb90356a0364a28904e6c0"}, + {file = "aiohttp-3.9.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9b05d5cbe9dafcdc733262c3a99ccf63d2f7ce02543620d2bd8db4d4f7a22f83"}, + {file = "aiohttp-3.9.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c4fa235d534b3547184831c624c0b7c1e262cd1de847d95085ec94c16fddcd5"}, + {file = "aiohttp-3.9.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:289ba9ae8e88d0ba16062ecf02dd730b34186ea3b1e7489046fc338bdc3361c4"}, + {file = "aiohttp-3.9.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bff7e2811814fa2271be95ab6e84c9436d027a0e59665de60edf44e529a42c1f"}, + {file = "aiohttp-3.9.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81b77f868814346662c96ab36b875d7814ebf82340d3284a31681085c051320f"}, + {file = "aiohttp-3.9.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3b9c7426923bb7bd66d409da46c41e3fb40f5caf679da624439b9eba92043fa6"}, + {file = "aiohttp-3.9.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:8d44e7bf06b0c0a70a20f9100af9fcfd7f6d9d3913e37754c12d424179b4e48f"}, + {file = "aiohttp-3.9.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:22698f01ff5653fe66d16ffb7658f582a0ac084d7da1323e39fd9eab326a1f26"}, + {file = "aiohttp-3.9.1-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:ca7ca5abfbfe8d39e653870fbe8d7710be7a857f8a8386fc9de1aae2e02ce7e4"}, + {file = "aiohttp-3.9.1-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:8d7f98fde213f74561be1d6d3fa353656197f75d4edfbb3d94c9eb9b0fc47f5d"}, + {file = "aiohttp-3.9.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:5216b6082c624b55cfe79af5d538e499cd5f5b976820eac31951fb4325974501"}, + {file = "aiohttp-3.9.1-cp310-cp310-win32.whl", hash = "sha256:0e7ba7ff228c0d9a2cd66194e90f2bca6e0abca810b786901a569c0de082f489"}, + {file = "aiohttp-3.9.1-cp310-cp310-win_amd64.whl", hash = "sha256:c7e939f1ae428a86e4abbb9a7c4732bf4706048818dfd979e5e2839ce0159f23"}, + {file = "aiohttp-3.9.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:df9cf74b9bc03d586fc53ba470828d7b77ce51b0582d1d0b5b2fb673c0baa32d"}, + {file = "aiohttp-3.9.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ecca113f19d5e74048c001934045a2b9368d77b0b17691d905af18bd1c21275e"}, + {file = "aiohttp-3.9.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8cef8710fb849d97c533f259103f09bac167a008d7131d7b2b0e3a33269185c0"}, + {file = "aiohttp-3.9.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bea94403a21eb94c93386d559bce297381609153e418a3ffc7d6bf772f59cc35"}, + {file = "aiohttp-3.9.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:91c742ca59045dce7ba76cab6e223e41d2c70d79e82c284a96411f8645e2afff"}, + {file = "aiohttp-3.9.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6c93b7c2e52061f0925c3382d5cb8980e40f91c989563d3d32ca280069fd6a87"}, + {file = "aiohttp-3.9.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ee2527134f95e106cc1653e9ac78846f3a2ec1004cf20ef4e02038035a74544d"}, + {file = "aiohttp-3.9.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:11ff168d752cb41e8492817e10fb4f85828f6a0142b9726a30c27c35a1835f01"}, + {file = "aiohttp-3.9.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:b8c3a67eb87394386847d188996920f33b01b32155f0a94f36ca0e0c635bf3e3"}, + {file = "aiohttp-3.9.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c7b5d5d64e2a14e35a9240b33b89389e0035e6de8dbb7ffa50d10d8b65c57449"}, + {file = "aiohttp-3.9.1-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:69985d50a2b6f709412d944ffb2e97d0be154ea90600b7a921f95a87d6f108a2"}, + {file = "aiohttp-3.9.1-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:c9110c06eaaac7e1f5562caf481f18ccf8f6fdf4c3323feab28a93d34cc646bd"}, + {file = "aiohttp-3.9.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d737e69d193dac7296365a6dcb73bbbf53bb760ab25a3727716bbd42022e8d7a"}, + {file = "aiohttp-3.9.1-cp311-cp311-win32.whl", hash = "sha256:4ee8caa925aebc1e64e98432d78ea8de67b2272252b0a931d2ac3bd876ad5544"}, + {file = "aiohttp-3.9.1-cp311-cp311-win_amd64.whl", hash = "sha256:a34086c5cc285be878622e0a6ab897a986a6e8bf5b67ecb377015f06ed316587"}, + {file = "aiohttp-3.9.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:f800164276eec54e0af5c99feb9494c295118fc10a11b997bbb1348ba1a52065"}, + {file = "aiohttp-3.9.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:500f1c59906cd142d452074f3811614be04819a38ae2b3239a48b82649c08821"}, + {file = "aiohttp-3.9.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0b0a6a36ed7e164c6df1e18ee47afbd1990ce47cb428739d6c99aaabfaf1b3af"}, + {file = "aiohttp-3.9.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69da0f3ed3496808e8cbc5123a866c41c12c15baaaead96d256477edf168eb57"}, + {file = "aiohttp-3.9.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:176df045597e674fa950bf5ae536be85699e04cea68fa3a616cf75e413737eb5"}, + {file = "aiohttp-3.9.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b796b44111f0cab6bbf66214186e44734b5baab949cb5fb56154142a92989aeb"}, + {file = "aiohttp-3.9.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f27fdaadce22f2ef950fc10dcdf8048407c3b42b73779e48a4e76b3c35bca26c"}, + {file = "aiohttp-3.9.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bcb6532b9814ea7c5a6a3299747c49de30e84472fa72821b07f5a9818bce0f66"}, + {file = "aiohttp-3.9.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:54631fb69a6e44b2ba522f7c22a6fb2667a02fd97d636048478db2fd8c4e98fe"}, + {file = "aiohttp-3.9.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:4b4c452d0190c5a820d3f5c0f3cd8a28ace48c54053e24da9d6041bf81113183"}, + {file = "aiohttp-3.9.1-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:cae4c0c2ca800c793cae07ef3d40794625471040a87e1ba392039639ad61ab5b"}, + {file = "aiohttp-3.9.1-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:565760d6812b8d78d416c3c7cfdf5362fbe0d0d25b82fed75d0d29e18d7fc30f"}, + {file = "aiohttp-3.9.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:54311eb54f3a0c45efb9ed0d0a8f43d1bc6060d773f6973efd90037a51cd0a3f"}, + {file = "aiohttp-3.9.1-cp312-cp312-win32.whl", hash = "sha256:85c3e3c9cb1d480e0b9a64c658cd66b3cfb8e721636ab8b0e746e2d79a7a9eed"}, + {file = "aiohttp-3.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:11cb254e397a82efb1805d12561e80124928e04e9c4483587ce7390b3866d213"}, + {file = "aiohttp-3.9.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:8a22a34bc594d9d24621091d1b91511001a7eea91d6652ea495ce06e27381f70"}, + {file = "aiohttp-3.9.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:598db66eaf2e04aa0c8900a63b0101fdc5e6b8a7ddd805c56d86efb54eb66672"}, + {file = "aiohttp-3.9.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:2c9376e2b09895c8ca8b95362283365eb5c03bdc8428ade80a864160605715f1"}, + {file = "aiohttp-3.9.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:41473de252e1797c2d2293804e389a6d6986ef37cbb4a25208de537ae32141dd"}, + {file = "aiohttp-3.9.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9c5857612c9813796960c00767645cb5da815af16dafb32d70c72a8390bbf690"}, + {file = "aiohttp-3.9.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ffcd828e37dc219a72c9012ec44ad2e7e3066bec6ff3aaa19e7d435dbf4032ca"}, + {file = "aiohttp-3.9.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:219a16763dc0294842188ac8a12262b5671817042b35d45e44fd0a697d8c8361"}, + {file = "aiohttp-3.9.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f694dc8a6a3112059258a725a4ebe9acac5fe62f11c77ac4dcf896edfa78ca28"}, + {file = "aiohttp-3.9.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:bcc0ea8d5b74a41b621ad4a13d96c36079c81628ccc0b30cfb1603e3dfa3a014"}, + {file = "aiohttp-3.9.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:90ec72d231169b4b8d6085be13023ece8fa9b1bb495e4398d847e25218e0f431"}, + {file = "aiohttp-3.9.1-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:cf2a0ac0615842b849f40c4d7f304986a242f1e68286dbf3bd7a835e4f83acfd"}, + {file = "aiohttp-3.9.1-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:0e49b08eafa4f5707ecfb321ab9592717a319e37938e301d462f79b4e860c32a"}, + {file = "aiohttp-3.9.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:2c59e0076ea31c08553e868cec02d22191c086f00b44610f8ab7363a11a5d9d8"}, + {file = "aiohttp-3.9.1-cp38-cp38-win32.whl", hash = "sha256:4831df72b053b1eed31eb00a2e1aff6896fb4485301d4ccb208cac264b648db4"}, + {file = "aiohttp-3.9.1-cp38-cp38-win_amd64.whl", hash = "sha256:3135713c5562731ee18f58d3ad1bf41e1d8883eb68b363f2ffde5b2ea4b84cc7"}, + {file = "aiohttp-3.9.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:cfeadf42840c1e870dc2042a232a8748e75a36b52d78968cda6736de55582766"}, + {file = "aiohttp-3.9.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:70907533db712f7aa791effb38efa96f044ce3d4e850e2d7691abd759f4f0ae0"}, + {file = "aiohttp-3.9.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:cdefe289681507187e375a5064c7599f52c40343a8701761c802c1853a504558"}, + {file = "aiohttp-3.9.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7481f581251bb5558ba9f635db70908819caa221fc79ee52a7f58392778c636"}, + {file = "aiohttp-3.9.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:49f0c1b3c2842556e5de35f122fc0f0b721334ceb6e78c3719693364d4af8499"}, + {file = "aiohttp-3.9.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0d406b01a9f5a7e232d1b0d161b40c05275ffbcbd772dc18c1d5a570961a1ca4"}, + {file = "aiohttp-3.9.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d8e4450e7fe24d86e86b23cc209e0023177b6d59502e33807b732d2deb6975f"}, + {file = "aiohttp-3.9.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c0266cd6f005e99f3f51e583012de2778e65af6b73860038b968a0a8888487a"}, + {file = "aiohttp-3.9.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ab221850108a4a063c5b8a70f00dd7a1975e5a1713f87f4ab26a46e5feac5a0e"}, + {file = "aiohttp-3.9.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:c88a15f272a0ad3d7773cf3a37cc7b7d077cbfc8e331675cf1346e849d97a4e5"}, + {file = "aiohttp-3.9.1-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:237533179d9747080bcaad4d02083ce295c0d2eab3e9e8ce103411a4312991a0"}, + {file = "aiohttp-3.9.1-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:02ab6006ec3c3463b528374c4cdce86434e7b89ad355e7bf29e2f16b46c7dd6f"}, + {file = "aiohttp-3.9.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04fa38875e53eb7e354ece1607b1d2fdee2d175ea4e4d745f6ec9f751fe20c7c"}, + {file = "aiohttp-3.9.1-cp39-cp39-win32.whl", hash = "sha256:82eefaf1a996060602f3cc1112d93ba8b201dbf5d8fd9611227de2003dddb3b7"}, + {file = "aiohttp-3.9.1-cp39-cp39-win_amd64.whl", hash = "sha256:9b05d33ff8e6b269e30a7957bd3244ffbce2a7a35a81b81c382629b80af1a8bf"}, + {file = "aiohttp-3.9.1.tar.gz", hash = "sha256:8fc49a87ac269d4529da45871e2ffb6874e87779c3d0e2ccd813c0899221239d"}, ] [package.dependencies] diff --git a/tests/unit_tests/chain/test_command_chain_best_effort.py b/tests/unit_tests/chain/test_command_chain_best_effort.py index b90e284..46cce10 100644 --- a/tests/unit_tests/chain/test_command_chain_best_effort.py +++ b/tests/unit_tests/chain/test_command_chain_best_effort.py @@ -10,11 +10,7 @@ from aidial_assistant.chain.callbacks.result_callback import ResultCallback from aidial_assistant.chain.command_chain import CommandChain from aidial_assistant.chain.history import History, ScopedMessage -from aidial_assistant.chain.model_client import ( - Message, - ModelClient, - UsagePublisher, -) +from aidial_assistant.chain.model_client import Message, ModelClient from aidial_assistant.commands.base import Command, TextResult from tests.utils.async_helper import to_async_string, to_async_strings @@ -54,12 +50,10 @@ async def test_model_doesnt_support_protocol(): model_client.agenerate.side_effect = to_async_strings( ["cannot reply in JSON format", BEST_EFFORT_ANSWER] ) - usage_publisher = Mock(spec=UsagePublisher) command_chain = CommandChain( name="TEST", model_client=model_client, command_dict={}, - usage_publisher=usage_publisher, max_retry_count=0, ) chain_callback = Mock(spec=ChainCallback) @@ -79,15 +73,13 @@ async def test_model_doesnt_support_protocol(): [ Message.system(f"system_prefix={SYSTEM_MESSAGE}"), Message.user(f"{USER_MESSAGE}{ENFORCE_JSON_FORMAT}"), - ], - usage_publisher, + ] ), call( [ Message.system(SYSTEM_MESSAGE), Message.user(USER_MESSAGE), - ], - usage_publisher, + ] ), ] @@ -104,12 +96,10 @@ async def test_model_partially_supports_protocol(): ) test_command = Mock(spec=Command) test_command.execute.return_value = TextResult(TEST_COMMAND_OUTPUT) - usage_publisher = Mock(spec=UsagePublisher) command_chain = CommandChain( name="TEST", model_client=model_client, command_dict={TEST_COMMAND_NAME: lambda *_: test_command}, - usage_publisher=usage_publisher, max_retry_count=0, ) chain_callback = MagicMock(spec=ChainCallback) @@ -133,8 +123,7 @@ async def test_model_partially_supports_protocol(): [ Message.system(f"system_prefix={SYSTEM_MESSAGE}"), Message.user(f"{USER_MESSAGE}{ENFORCE_JSON_FORMAT}"), - ], - usage_publisher, + ] ), call( [ @@ -142,8 +131,7 @@ async def test_model_partially_supports_protocol(): Message.user(USER_MESSAGE), Message.assistant(TEST_COMMAND_REQUEST), Message.user(f"{TEST_COMMAND_RESPONSE}{ENFORCE_JSON_FORMAT}"), - ], - usage_publisher, + ] ), call( [ @@ -151,8 +139,7 @@ async def test_model_partially_supports_protocol(): Message.user( f"user_message={USER_MESSAGE}, error={FAILED_PROTOCOL_ERROR}, dialogue={succeeded_dialogue}" ), - ], - usage_publisher, + ] ), ] @@ -167,12 +154,10 @@ async def test_no_tokens_for_tools(): ] test_command = Mock(spec=Command) test_command.execute.return_value = TextResult(TEST_COMMAND_OUTPUT) - usage_publisher = Mock(spec=UsagePublisher) command_chain = CommandChain( name="TEST", model_client=model_client, command_dict={TEST_COMMAND_NAME: lambda *_: test_command}, - usage_publisher=usage_publisher, max_retry_count=0, ) chain_callback = MagicMock(spec=ChainCallback) @@ -192,8 +177,7 @@ async def test_no_tokens_for_tools(): [ Message.system(f"system_prefix={SYSTEM_MESSAGE}"), Message.user(f"{USER_MESSAGE}{ENFORCE_JSON_FORMAT}"), - ], - usage_publisher, + ] ), call( [ @@ -201,8 +185,7 @@ async def test_no_tokens_for_tools(): Message.user(USER_MESSAGE), Message.assistant(TEST_COMMAND_REQUEST), Message.user(f"{TEST_COMMAND_RESPONSE}{ENFORCE_JSON_FORMAT}"), - ], - usage_publisher, + ] ), call( [ @@ -210,7 +193,6 @@ async def test_no_tokens_for_tools(): Message.user( f"user_message={USER_MESSAGE}, error={NO_TOKENS_ERROR}, dialogue=[]" ), - ], - usage_publisher, + ] ), ] diff --git a/tests/unit_tests/chain/test_history.py b/tests/unit_tests/chain/test_history.py index ae80332..c810e03 100644 --- a/tests/unit_tests/chain/test_history.py +++ b/tests/unit_tests/chain/test_history.py @@ -1,37 +1,155 @@ +from typing import AsyncIterator +from unittest.mock import Mock + +import pytest from jinja2 import Template +from pydantic import BaseModel from aidial_assistant.chain.history import History, MessageScope, ScopedMessage -from aidial_assistant.chain.model_client import Message +from aidial_assistant.chain.model_client import ( + ExtraResultsCallback, + Message, + ModelClient, + ReasonLengthException, +) + +TRUNCATION_TEST_DATA = [ + (0, [0, 1, 2, 3, 4, 5, 6]), + (1, [0, 2, 3, 4, 5, 6]), + (2, [0, 2, 6]), + (3, [0, 2, 6]), + (4, [0, 2, 6]), +] + +MAX_PROMPT_TOKENS = 123 -SYSTEM_MESSAGE = "" -USER_MESSAGE = "" -ASSISTANT_MESSAGE = "" +class ModelSideEffect(BaseModel): + discarded_messages: int -def test_protocol_messages(): + async def agenerate( + self, _, callback: ExtraResultsCallback, **kwargs + ) -> AsyncIterator[str]: + callback.on_discarded_messages(self.discarded_messages) + yield "dummy" + raise ReasonLengthException() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "discarded_messages,expected_indices", TRUNCATION_TEST_DATA +) +async def test_history_truncation( + discarded_messages: int, expected_indices: list[int] +): history = History( - assistant_system_message_template=Template( - "system message={{system_prefix}}" - ), + assistant_system_message_template=Template(""), best_effort_template=Template(""), scoped_messages=[ + ScopedMessage(message=Message.system(content="a")), + ScopedMessage(message=Message.user(content="b")), + ScopedMessage(message=Message.system(content="c")), ScopedMessage( - scope=MessageScope.USER, message=Message.system(SYSTEM_MESSAGE) + message=Message.assistant(content="d"), + scope=MessageScope.INTERNAL, ), ScopedMessage( - scope=MessageScope.USER, message=Message.user(USER_MESSAGE) + message=Message.user(content="e"), scope=MessageScope.INTERNAL ), + ScopedMessage(message=Message.assistant(content="f")), + ScopedMessage(message=Message.user(content="g")), + ], + ) + + side_effect = ModelSideEffect(discarded_messages=discarded_messages) + model_client = Mock(spec=ModelClient) + model_client.agenerate.side_effect = side_effect.agenerate + + actual = await history.truncate(MAX_PROMPT_TOKENS, model_client) + + assert ( + actual.assistant_system_message_template + == history.assistant_system_message_template + ) + assert actual.best_effort_template == history.best_effort_template + assert actual.scoped_messages == [ + history.scoped_messages[i] for i in expected_indices + ] + assert ( + model_client.agenerate.call_args.kwargs["max_prompt_tokens"] + == MAX_PROMPT_TOKENS + ) + + +@pytest.mark.asyncio +async def test_truncation_overflow(): + history = History( + assistant_system_message_template=Template(""), + best_effort_template=Template(""), + scoped_messages=[ + ScopedMessage(message=Message.system(content="a")), + ScopedMessage(message=Message.user(content="b")), + ], + ) + + side_effect = ModelSideEffect(discarded_messages=1) + model_client = Mock(spec=ModelClient) + model_client.agenerate.side_effect = side_effect.agenerate + + with pytest.raises(Exception) as exc_info: + await history.truncate(MAX_PROMPT_TOKENS, model_client) + + assert ( + str(exc_info.value) == "No user messages left after history truncation." + ) + + +@pytest.mark.asyncio +async def test_truncation_with_incorrect_message_sequence(): + history = History( + assistant_system_message_template=Template(""), + best_effort_template=Template(""), + scoped_messages=[ ScopedMessage( - scope=MessageScope.USER, - message=Message.assistant(ASSISTANT_MESSAGE), + message=Message.user(content="a"), scope=MessageScope.INTERNAL ), + ScopedMessage(message=Message.user(content="b")), + ], + ) + + side_effect = ModelSideEffect(discarded_messages=1) + model_client = Mock(spec=ModelClient) + model_client.agenerate.side_effect = side_effect.agenerate + + with pytest.raises(Exception) as exc_info: + await history.truncate(MAX_PROMPT_TOKENS, model_client) + + assert ( + str(exc_info.value) + == "Internal messages must be followed by an assistant reply." + ) + + +def test_protocol_messages_with_system_message(): + system_message = "" + user_message = "" + assistant_message = "" + history = History( + assistant_system_message_template=Template( + "system message={{system_prefix}}" + ), + best_effort_template=Template(""), + scoped_messages=[ + ScopedMessage(message=Message.system(system_message)), + ScopedMessage(message=Message.user(user_message)), + ScopedMessage(message=Message.assistant(assistant_message)), ], ) assert history.to_protocol_messages() == [ - Message.system(f"system message={SYSTEM_MESSAGE}"), - Message.user(USER_MESSAGE), + Message.system(f"system message={system_message}"), + Message.user(user_message), Message.assistant( - f'{{"commands": [{{"command": "reply", "args": ["{ASSISTANT_MESSAGE}"]}}]}}' + f'{{"commands": [{{"command": "reply", "args": ["{assistant_message}"]}}]}}' ), ] diff --git a/tests/unit_tests/chain/test_model_client.py b/tests/unit_tests/chain/test_model_client.py new file mode 100644 index 0000000..0def914 --- /dev/null +++ b/tests/unit_tests/chain/test_model_client.py @@ -0,0 +1,105 @@ +from unittest import mock +from unittest.mock import Mock, call + +import pytest + +from aidial_assistant.chain.model_client import ( + ExtraResultsCallback, + Message, + ModelClient, + ReasonLengthException, +) +from aidial_assistant.utils.text import join_string +from tests.utils.async_helper import to_async_iterator + +API_METHOD = "openai.ChatCompletion.acreate" +MODEL_ARGS = {"model": "args"} +BUFFER_SIZE = 321 + + +@mock.patch(API_METHOD) +@pytest.mark.asyncio +async def test_discarded_messages(api): + model_client = ModelClient(MODEL_ARGS, BUFFER_SIZE) + api.return_value = to_async_iterator( + [ + { + "choices": [{"delta": {"content": ""}}], + "statistics": {"discarded_messages": 2}, + } + ] + ) + extra_results_callback = Mock(spec=ExtraResultsCallback) + + await join_string(model_client.agenerate([], extra_results_callback)) + + assert extra_results_callback.on_discarded_messages.call_args_list == [ + call(2) + ] + + +@mock.patch(API_METHOD) +@pytest.mark.asyncio +async def test_content(api): + model_client = ModelClient(MODEL_ARGS, BUFFER_SIZE) + api.return_value = to_async_iterator( + [ + {"choices": [{"delta": {"content": "one, "}}]}, + {"choices": [{"delta": {"content": "two, "}}]}, + {"choices": [{"delta": {"content": "three"}}]}, + ] + ) + + assert await join_string(model_client.agenerate([])) == "one, two, three" + + +@mock.patch(API_METHOD) +@pytest.mark.asyncio +async def test_reason_length_with_usage(api): + model_client = ModelClient(MODEL_ARGS, BUFFER_SIZE) + api.return_value = to_async_iterator( + [ + {"choices": [{"delta": {"content": "text"}}]}, + { + "choices": [ + {"delta": {"content": ""}, "finish_reason": "length"} # type: ignore + ] + }, + { + "choices": [{"delta": {"content": ""}}], + "usage": {"prompt_tokens": 1, "completion_tokens": 2}, + }, + ] + ) + + with pytest.raises(ReasonLengthException): + async for chunk in model_client.agenerate([]): + assert chunk == "text" + + assert model_client.prompt_tokens == 1 + assert model_client.completion_tokens == 2 + + +@mock.patch(API_METHOD) +@pytest.mark.asyncio +async def test_api_args(api): + model_client = ModelClient(MODEL_ARGS, BUFFER_SIZE) + api.return_value = to_async_iterator([]) + messages = [ + Message.system(content="a"), + Message.user(content="b"), + Message.assistant(content="c"), + ] + + await join_string(model_client.agenerate(messages)) + + assert api.call_args_list == [ + call( + messages=[ + {"role": "system", "content": "a"}, + {"role": "user", "content": "b"}, + {"role": "assistant", "content": "c"}, + ], + **MODEL_ARGS, + ) + ] diff --git a/tests/unit_tests/utils/test_exception_handler.py b/tests/unit_tests/utils/test_exception_handler.py new file mode 100644 index 0000000..caf73b2 --- /dev/null +++ b/tests/unit_tests/utils/test_exception_handler.py @@ -0,0 +1,89 @@ +import pytest +from aidial_sdk import HTTPException +from openai import OpenAIError + +from aidial_assistant.utils.exceptions import ( + RequestParameterValidationError, + unhandled_exception_handler, +) + +ERROR_MESSAGE = "" +PARAM = "" + + +@pytest.mark.asyncio +async def test_request_parameter_validation_error(): + @unhandled_exception_handler + async def function(): + raise RequestParameterValidationError(ERROR_MESSAGE, PARAM) + + with pytest.raises(HTTPException) as exc_info: + await function() + + assert ( + repr(exc_info.value) + == f"HTTPException(message='{ERROR_MESSAGE}', status_code=422," + f" type='invalid_request_error', param='{PARAM}', code=None)" + ) + + +@pytest.mark.asyncio +async def test_openai_error(): + http_status = 123 + + @unhandled_exception_handler + async def function(): + raise OpenAIError(message=ERROR_MESSAGE, http_status=http_status) + + with pytest.raises(HTTPException) as exc_info: + await function() + + assert ( + repr(exc_info.value) + == f"HTTPException(message='{ERROR_MESSAGE}', status_code={http_status}," + f" type='runtime_error', param=None, code=None)" + ) + + +@pytest.mark.asyncio +async def test_openai_error_with_json_body(): + http_status = 123 + error_type = "" + error_code = "" + json_body = { + "error": { + "message": ERROR_MESSAGE, + "type": error_type, + "code": error_code, + "param": PARAM, + } + } + + @unhandled_exception_handler + async def function(): + raise OpenAIError(json_body=json_body, http_status=http_status) + + with pytest.raises(HTTPException) as exc_info: + await function() + + assert ( + repr(exc_info.value) + == f"HTTPException(message='{ERROR_MESSAGE}', status_code={http_status}," + f" type='{error_type}', param='{PARAM}', code='{error_code}')" + ) + + +@pytest.mark.asyncio +async def test_generic_exception(): + @unhandled_exception_handler + async def function(): + raise Exception(ERROR_MESSAGE) + + with pytest.raises(HTTPException) as exc_info: + await function() + + assert ( + repr(exc_info.value) + == f"HTTPException(message='{ERROR_MESSAGE}', status_code=500," + f" type='internal_server_error', param=None, code=None)" + ) diff --git a/tests/utils/async_helper.py b/tests/utils/async_helper.py index dbb8d72..00e3bbb 100644 --- a/tests/utils/async_helper.py +++ b/tests/utils/async_helper.py @@ -1,4 +1,6 @@ -from typing import AsyncIterator +from typing import AsyncIterator, Iterable, TypeVar + +T = TypeVar("T") async def to_async_string(string: str) -> AsyncIterator[str]: @@ -13,3 +15,8 @@ def to_async_repeated_string( string: str, count: int ) -> list[AsyncIterator[str]]: return [to_async_string(string) for _ in range(count)] + + +async def to_async_iterator(sequence: Iterable[T]) -> AsyncIterator[T]: + for item in sequence: + yield item