From ae54b398d29c773b5a89a6cf7b0396d97f8ac370 Mon Sep 17 00:00:00 2001 From: Krrish Dholakia Date: Fri, 8 Mar 2024 13:58:15 -0800 Subject: [PATCH 1/6] feat(router.py): add load balancing for async transcription calls --- litellm/main.py | 1 + litellm/router.py | 80 ++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 80 insertions(+), 1 deletion(-) diff --git a/litellm/main.py b/litellm/main.py index f1a745fccda..a04dba4ecac 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -3313,6 +3313,7 @@ def image_generation( ##### Transcription ####################### +@client async def atranscription(*args, **kwargs): """ Calls openai + azure whisper endpoints. diff --git a/litellm/router.py b/litellm/router.py index d4c0be8622a..10f7058b3e1 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -9,7 +9,7 @@ import copy, httpx from datetime import datetime -from typing import Dict, List, Optional, Union, Literal, Any +from typing import Dict, List, Optional, Union, Literal, Any, BinaryIO import random, threading, time, traceback, uuid import litellm, openai from litellm.caching import RedisCache, InMemoryCache, DualCache @@ -633,6 +633,84 @@ class Router: self.fail_calls[model_name] += 1 raise e + async def atranscription(self, file: BinaryIO, model: str, **kwargs): + try: + kwargs["model"] = model + kwargs["file"] = file + kwargs["original_function"] = self._atranscription + kwargs["num_retries"] = kwargs.get("num_retries", self.num_retries) + timeout = kwargs.get("request_timeout", self.timeout) + kwargs.setdefault("metadata", {}).update({"model_group": model}) + response = await self.async_function_with_fallbacks(**kwargs) + + return response + except Exception as e: + raise e + + async def _atranscription(self, file: BinaryIO, model: str, **kwargs): + try: + verbose_router_logger.debug( + f"Inside _atranscription()- model: {model}; kwargs: {kwargs}" + ) + deployment = self.get_available_deployment( + model=model, + messages=[{"role": "user", "content": "prompt"}], + specific_deployment=kwargs.pop("specific_deployment", None), + ) + kwargs.setdefault("metadata", {}).update( + { + "deployment": deployment["litellm_params"]["model"], + "model_info": deployment.get("model_info", {}), + } + ) + kwargs["model_info"] = deployment.get("model_info", {}) + data = deployment["litellm_params"].copy() + model_name = data["model"] + for k, v in self.default_litellm_params.items(): + if ( + k not in kwargs + ): # prioritize model-specific params > default router params + kwargs[k] = v + elif k == "metadata": + kwargs[k].update(v) + + potential_model_client = self._get_client( + deployment=deployment, kwargs=kwargs, client_type="async" + ) + # check if provided keys == client keys # + dynamic_api_key = kwargs.get("api_key", None) + if ( + dynamic_api_key is not None + and potential_model_client is not None + and dynamic_api_key != potential_model_client.api_key + ): + model_client = None + else: + model_client = potential_model_client + + self.total_calls[model_name] += 1 + response = await litellm.atranscription( + **{ + **data, + "file": file, + "caching": self.cache_responses, + "client": model_client, + **kwargs, + } + ) + self.success_calls[model_name] += 1 + verbose_router_logger.info( + f"litellm.atranscription(model={model_name})\033[32m 200 OK\033[0m" + ) + return response + except Exception as e: + verbose_router_logger.info( + f"litellm.atranscription(model={model_name})\033[31m Exception {str(e)}\033[0m" + ) + if model_name is not None: + self.fail_calls[model_name] += 1 + raise e + async def amoderation(self, model: str, input: str, **kwargs): try: kwargs["model"] = model From fe125a5131cd73321791adc79d84498c5dbf20d3 Mon Sep 17 00:00:00 2001 From: Krrish Dholakia Date: Fri, 8 Mar 2024 14:19:37 -0800 Subject: [PATCH 2/6] test(test_whisper.py): add testing for load balancing whisper endpoints on router --- litellm/router.py | 22 ++++++++++++++++++++ tests/test_whisper.py | 47 ++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 66 insertions(+), 3 deletions(-) diff --git a/litellm/router.py b/litellm/router.py index 10f7058b3e1..71339aa36cf 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -634,6 +634,28 @@ class Router: raise e async def atranscription(self, file: BinaryIO, model: str, **kwargs): + """ + Example Usage: + + ``` + from litellm import Router + client = Router(model_list = [ + { + "model_name": "whisper", + "litellm_params": { + "model": "whisper-1", + }, + }, + ]) + + audio_file = open("speech.mp3", "rb") + transcript = await client.atranscription( + model="whisper", + file=audio_file + ) + + ``` + """ try: kwargs["model"] = model kwargs["file"] = file diff --git a/tests/test_whisper.py b/tests/test_whisper.py index 9d8f038c292..bb097173256 100644 --- a/tests/test_whisper.py +++ b/tests/test_whisper.py @@ -1,14 +1,20 @@ # What is this? -## Tests `litellm.transcription` endpoint +## Tests `litellm.transcription` endpoint. Outside litellm module b/c of audio file used in testing (it's ~700kb). + import pytest import asyncio, time -import aiohttp +import aiohttp, traceback from openai import AsyncOpenAI import sys, os, dotenv from typing import Optional from dotenv import load_dotenv -audio_file = open("./gettysburg.wav", "rb") +pwd = os.path.dirname(os.path.realpath(__file__)) +print(pwd) + +file_path = os.path.join(pwd, "gettysburg.wav") + +audio_file = open(file_path, "rb") load_dotenv() @@ -16,6 +22,7 @@ sys.path.insert( 0, os.path.abspath("../") ) # Adds the parent directory to the system path import litellm +from litellm import Router def test_transcription(): @@ -71,3 +78,37 @@ async def test_transcription_async_openai(): assert transcript.text is not None assert isinstance(transcript.text, str) + + +@pytest.mark.asyncio +async def test_transcription_on_router(): + litellm.set_verbose = True + print("\n Testing async transcription on router\n") + try: + model_list = [ + { + "model_name": "whisper", + "litellm_params": { + "model": "whisper-1", + }, + }, + { + "model_name": "whisper", + "litellm_params": { + "model": "azure/azure-whisper", + "api_base": os.getenv("AZURE_EUROPE_API_BASE"), + "api_key": os.getenv("AZURE_EUROPE_API_KEY"), + "api_version": os.getenv("2024-02-15-preview"), + }, + }, + ] + + router = Router(model_list=model_list) + response = await router.atranscription( + model="whisper", + file=audio_file, + ) + print(response) + except Exception as e: + traceback.print_exc() + pytest.fail(f"Error occurred: {e}") From 8c6d5b7f16549b740b4f7c2739f224ab29295fa0 Mon Sep 17 00:00:00 2001 From: Krrish Dholakia Date: Fri, 8 Mar 2024 14:28:07 -0800 Subject: [PATCH 3/6] feat(proxy_server.py): supports `/audio/transcription` endpoint on proxy --- litellm/proxy/proxy_server.py | 147 ++++++++++++++++++++++++++++++++++ 1 file changed, 147 insertions(+) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 19ac5c96118..c90f14a97f9 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -3213,6 +3213,153 @@ async def image_generation( ) +@router.post( + "/v1/audio/transcriptions", + dependencies=[Depends(user_api_key_auth)], + response_class=ORJSONResponse, + tags=["image generation"], +) +@router.post( + "/audio/transcriptions", + dependencies=[Depends(user_api_key_auth)], + response_class=ORJSONResponse, + tags=["image generation"], +) +async def audio_transcriptions( + request: Request, + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), +): + """ + Same params as: + + https://platform.openai.com/docs/api-reference/audio/createTranscription?lang=curl + """ + global proxy_logging_obj + try: + # Use orjson to parse JSON data, orjson speeds up requests significantly + body = await request.body() + data = orjson.loads(body) + + # Include original request and headers in the data + data["proxy_server_request"] = { + "url": str(request.url), + "method": request.method, + "headers": dict(request.headers), + "body": copy.copy(data), # use copy instead of deepcopy + } + + if data.get("user", None) is None and user_api_key_dict.user_id is not None: + data["user"] = user_api_key_dict.user_id + + data["model"] = ( + general_settings.get("moderation_model", None) # server default + or user_model # model name passed via cli args + or data["model"] # default passed in http request + ) + if user_model: + data["model"] = user_model + + if "metadata" not in data: + data["metadata"] = {} + data["metadata"]["user_api_key"] = user_api_key_dict.api_key + data["metadata"]["user_api_key_metadata"] = user_api_key_dict.metadata + _headers = dict(request.headers) + _headers.pop( + "authorization", None + ) # do not store the original `sk-..` api key in the db + data["metadata"]["headers"] = _headers + data["metadata"]["user_api_key_alias"] = getattr( + user_api_key_dict, "key_alias", None + ) + data["metadata"]["user_api_key_user_id"] = user_api_key_dict.user_id + data["metadata"]["user_api_key_team_id"] = getattr( + user_api_key_dict, "team_id", None + ) + data["metadata"]["endpoint"] = str(request.url) + + ### TEAM-SPECIFIC PARAMS ### + if user_api_key_dict.team_id is not None: + team_config = await proxy_config.load_team_config( + team_id=user_api_key_dict.team_id + ) + if len(team_config) == 0: + pass + else: + team_id = team_config.pop("team_id", None) + data["metadata"]["team_id"] = team_id + data = { + **team_config, + **data, + } # add the team-specific configs to the completion call + + router_model_names = ( + [m["model_name"] for m in llm_model_list] + if llm_model_list is not None + else [] + ) + + ### CALL HOOKS ### - modify incoming data / reject request before calling the model + data = await proxy_logging_obj.pre_call_hook( + user_api_key_dict=user_api_key_dict, data=data, call_type="moderation" + ) + + start_time = time.time() + + ## ROUTE TO CORRECT ENDPOINT ## + # skip router if user passed their key + if "api_key" in data: + response = await litellm.atranscription(**data) + elif ( + llm_router is not None and data["model"] in router_model_names + ): # model in router model list + response = await llm_router.atranscription(**data) + elif ( + llm_router is not None and data["model"] in llm_router.deployment_names + ): # model in router deployments, calling a specific deployment on the router + response = await llm_router.atranscription(**data, specific_deployment=True) + elif ( + llm_router is not None + and llm_router.model_group_alias is not None + and data["model"] in llm_router.model_group_alias + ): # model set in model_group_alias + response = await llm_router.atranscription( + **data + ) # ensure this goes the llm_router, router will do the correct alias mapping + elif user_model is not None: # `litellm --model ` + response = await litellm.atranscription(**data) + else: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"error": "Invalid model name passed in"}, + ) + + ### ALERTING ### + data["litellm_status"] = "success" # used for alerting + + return response + except Exception as e: + await proxy_logging_obj.post_call_failure_hook( + user_api_key_dict=user_api_key_dict, original_exception=e + ) + traceback.print_exc() + if isinstance(e, HTTPException): + raise ProxyException( + message=getattr(e, "message", str(e)), + type=getattr(e, "type", "None"), + param=getattr(e, "param", "None"), + code=getattr(e, "status_code", status.HTTP_400_BAD_REQUEST), + ) + else: + error_traceback = traceback.format_exc() + error_msg = f"{str(e)}\n\n{error_traceback}" + raise ProxyException( + message=getattr(e, "message", error_msg), + type=getattr(e, "type", "None"), + param=getattr(e, "param", "None"), + code=getattr(e, "status_code", 500), + ) + + @router.post( "/v1/moderations", dependencies=[Depends(user_api_key_auth)], From cc0294b2f2346c0870b446112dc1fdc9826e1676 Mon Sep 17 00:00:00 2001 From: Krrish Dholakia Date: Fri, 8 Mar 2024 14:29:31 -0800 Subject: [PATCH 4/6] fix(proxy_server.py): fix tagging of endpoints --- litellm/proxy/proxy_server.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index c90f14a97f9..27eeea5733b 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -3066,13 +3066,13 @@ async def embeddings( "/v1/images/generations", dependencies=[Depends(user_api_key_auth)], response_class=ORJSONResponse, - tags=["image generation"], + tags=["images"], ) @router.post( "/images/generations", dependencies=[Depends(user_api_key_auth)], response_class=ORJSONResponse, - tags=["image generation"], + tags=["images"], ) async def image_generation( request: Request, @@ -3217,13 +3217,13 @@ async def image_generation( "/v1/audio/transcriptions", dependencies=[Depends(user_api_key_auth)], response_class=ORJSONResponse, - tags=["image generation"], + tags=["audio"], ) @router.post( "/audio/transcriptions", dependencies=[Depends(user_api_key_auth)], response_class=ORJSONResponse, - tags=["image generation"], + tags=["audio"], ) async def audio_transcriptions( request: Request, From 0fb7afe8202c65e277e962cc0571207cad357e67 Mon Sep 17 00:00:00 2001 From: Krrish Dholakia Date: Fri, 8 Mar 2024 18:20:27 -0800 Subject: [PATCH 5/6] feat(proxy_server.py): working `/audio/transcription` endpoint --- litellm/llms/azure.py | 27 +++++++--- litellm/llms/openai.py | 4 +- litellm/main.py | 7 +-- litellm/proxy/proxy_server.py | 95 +++++++++++++++++++++-------------- litellm/utils.py | 14 ++++-- tests/test_whisper.py | 2 +- 6 files changed, 95 insertions(+), 54 deletions(-) diff --git a/litellm/llms/azure.py b/litellm/llms/azure.py index e19023b035a..022266996da 100644 --- a/litellm/llms/azure.py +++ b/litellm/llms/azure.py @@ -770,9 +770,8 @@ class AzureChatCompletion(BaseLLM): api_version: Optional[str] = None, client=None, azure_ad_token: Optional[str] = None, - max_retries=None, logging_obj=None, - atranscriptions: bool = False, + atranscription: bool = False, ): data = {"model": model, "file": audio_file, **optional_params} @@ -781,9 +780,11 @@ class AzureChatCompletion(BaseLLM): "api_version": api_version, "azure_endpoint": api_base, "azure_deployment": model, - "max_retries": max_retries, "timeout": timeout, } + + max_retries = optional_params.pop("max_retries", None) + azure_client_params = select_azure_base_url_or_endpoint( azure_client_params=azure_client_params ) @@ -792,7 +793,10 @@ class AzureChatCompletion(BaseLLM): elif azure_ad_token is not None: azure_client_params["azure_ad_token"] = azure_ad_token - if atranscriptions == True: + if max_retries is not None: + azure_client_params["max_retries"] = max_retries + + if atranscription == True: return self.async_audio_transcriptions( audio_file=audio_file, data=data, @@ -845,18 +849,29 @@ class AzureChatCompletion(BaseLLM): ) else: async_azure_client = client + response = await async_azure_client.audio.transcriptions.create( **data, timeout=timeout ) # type: ignore + stringified_response = response.model_dump() + ## LOGGING logging_obj.post_call( input=audio_file.name, api_key=api_key, - additional_args={"complete_input_dict": data}, + additional_args={ + "headers": { + "Authorization": f"Bearer {async_azure_client.api_key}" + }, + "api_base": async_azure_client._base_url._uri_reference, + "atranscription": True, + "complete_input_dict": data, + }, original_response=stringified_response, ) - return convert_to_model_response_object(response_object=stringified_response, model_response_object=model_response, response_type="image_generation") # type: ignore + response = convert_to_model_response_object(response_object=stringified_response, model_response_object=model_response, response_type="audio_transcription") # type: ignore + return response except Exception as e: ## LOGGING logging_obj.post_call( diff --git a/litellm/llms/openai.py b/litellm/llms/openai.py index fca950d318c..a90d2457a10 100644 --- a/litellm/llms/openai.py +++ b/litellm/llms/openai.py @@ -779,10 +779,10 @@ class OpenAIChatCompletion(BaseLLM): client=None, max_retries=None, logging_obj=None, - atranscriptions: bool = False, + atranscription: bool = False, ): data = {"model": model, "file": audio_file, **optional_params} - if atranscriptions == True: + if atranscription == True: return self.async_audio_transcriptions( audio_file=audio_file, data=data, diff --git a/litellm/main.py b/litellm/main.py index a04dba4ecac..6deaf653f60 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -3385,7 +3385,7 @@ def transcription( Allows router to load balance between them """ - atranscriptions = kwargs.get("atranscriptions", False) + atranscription = kwargs.get("atranscription", False) litellm_call_id = kwargs.get("litellm_call_id", None) logger_fn = kwargs.get("logger_fn", None) proxy_server_request = kwargs.get("proxy_server_request", None) @@ -3421,12 +3421,13 @@ def transcription( or litellm.azure_key or get_secret("AZURE_API_KEY") ) + response = azure_chat_completions.audio_transcriptions( model=model, audio_file=file, optional_params=optional_params, model_response=model_response, - atranscriptions=atranscriptions, + atranscription=atranscription, timeout=timeout, logging_obj=litellm_logging_obj, api_base=api_base, @@ -3440,7 +3441,7 @@ def transcription( audio_file=file, optional_params=optional_params, model_response=model_response, - atranscriptions=atranscriptions, + atranscription=atranscription, timeout=timeout, logging_obj=litellm_logging_obj, ) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 27eeea5733b..7a04c8e7d23 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -120,6 +120,8 @@ from fastapi import ( Header, Response, Form, + UploadFile, + File, ) from fastapi.routing import APIRouter from fastapi.security import OAuth2PasswordBearer @@ -3216,17 +3218,16 @@ async def image_generation( @router.post( "/v1/audio/transcriptions", dependencies=[Depends(user_api_key_auth)], - response_class=ORJSONResponse, tags=["audio"], ) @router.post( "/audio/transcriptions", dependencies=[Depends(user_api_key_auth)], - response_class=ORJSONResponse, tags=["audio"], ) async def audio_transcriptions( request: Request, + file: UploadFile = File(...), user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), ): """ @@ -3237,11 +3238,11 @@ async def audio_transcriptions( global proxy_logging_obj try: # Use orjson to parse JSON data, orjson speeds up requests significantly - body = await request.body() - data = orjson.loads(body) + form_data = await request.form() + data: Dict = {key: value for key, value in form_data.items() if key != "file"} # Include original request and headers in the data - data["proxy_server_request"] = { + data["proxy_server_request"] = { # type: ignore "url": str(request.url), "method": request.method, "headers": dict(request.headers), @@ -3298,44 +3299,60 @@ async def audio_transcriptions( else [] ) - ### CALL HOOKS ### - modify incoming data / reject request before calling the model - data = await proxy_logging_obj.pre_call_hook( - user_api_key_dict=user_api_key_dict, data=data, call_type="moderation" - ) + assert ( + file.filename is not None + ) # make sure filename passed in (needed for type) - start_time = time.time() + with open(file.filename, "wb+") as f: + f.write(await file.read()) + try: + data["file"] = open(file.filename, "rb") + ### CALL HOOKS ### - modify incoming data / reject request before calling the model + data = await proxy_logging_obj.pre_call_hook( + user_api_key_dict=user_api_key_dict, + data=data, + call_type="moderation", + ) - ## ROUTE TO CORRECT ENDPOINT ## - # skip router if user passed their key - if "api_key" in data: - response = await litellm.atranscription(**data) - elif ( - llm_router is not None and data["model"] in router_model_names - ): # model in router model list - response = await llm_router.atranscription(**data) - elif ( - llm_router is not None and data["model"] in llm_router.deployment_names - ): # model in router deployments, calling a specific deployment on the router - response = await llm_router.atranscription(**data, specific_deployment=True) - elif ( - llm_router is not None - and llm_router.model_group_alias is not None - and data["model"] in llm_router.model_group_alias - ): # model set in model_group_alias - response = await llm_router.atranscription( - **data - ) # ensure this goes the llm_router, router will do the correct alias mapping - elif user_model is not None: # `litellm --model ` - response = await litellm.atranscription(**data) - else: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail={"error": "Invalid model name passed in"}, - ) + ## ROUTE TO CORRECT ENDPOINT ## + # skip router if user passed their key + if "api_key" in data: + response = await litellm.atranscription(**data) + elif ( + llm_router is not None and data["model"] in router_model_names + ): # model in router model list + response = await llm_router.atranscription(**data) + + elif ( + llm_router is not None + and data["model"] in llm_router.deployment_names + ): # model in router deployments, calling a specific deployment on the router + response = await llm_router.atranscription( + **data, specific_deployment=True + ) + elif ( + llm_router is not None + and llm_router.model_group_alias is not None + and data["model"] in llm_router.model_group_alias + ): # model set in model_group_alias + response = await llm_router.atranscription( + **data + ) # ensure this goes the llm_router, router will do the correct alias mapping + elif user_model is not None: # `litellm --model ` + response = await litellm.atranscription(**data) + else: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"error": "Invalid model name passed in"}, + ) + + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + finally: + os.remove(file.filename) # Delete the saved file ### ALERTING ### data["litellm_status"] = "success" # used for alerting - return response except Exception as e: await proxy_logging_obj.post_call_failure_hook( @@ -3344,7 +3361,7 @@ async def audio_transcriptions( traceback.print_exc() if isinstance(e, HTTPException): raise ProxyException( - message=getattr(e, "message", str(e)), + message=getattr(e, "message", str(e.detail)), type=getattr(e, "type", "None"), param=getattr(e, "param", "None"), code=getattr(e, "status_code", status.HTTP_400_BAD_REQUEST), diff --git a/litellm/utils.py b/litellm/utils.py index 330903f5ad3..3285f3a08a0 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -2309,7 +2309,7 @@ def client(original_function): or call_type == CallTypes.transcription.value ): _file_name: BinaryIO = args[1] if len(args) > 1 else kwargs["file"] - messages = _file_name.name + messages = "audio_file" stream = True if "stream" in kwargs and kwargs["stream"] == True else False logging_obj = Logging( model=model, @@ -2607,6 +2607,8 @@ def client(original_function): return result elif "aimg_generation" in kwargs and kwargs["aimg_generation"] == True: return result + elif "atranscription" in kwargs and kwargs["atranscription"] == True: + return result ### POST-CALL RULES ### post_call_processing(original_response=result, model=model or None) @@ -7834,7 +7836,9 @@ def exception_type( message=f"AzureException - {original_exception.message}", llm_provider="azure", model=model, - request=original_exception.request, + request=httpx.Request( + method="POST", url="https://openai.com/" + ), ) else: # if no status code then it is an APIConnectionError: https://github.com/openai/openai-python#handling-errors @@ -7842,7 +7846,11 @@ def exception_type( __cause__=original_exception.__cause__, llm_provider="azure", model=model, - request=original_exception.request, + request=getattr( + original_exception, + "request", + httpx.Request(method="POST", url="https://openai.com/"), + ), ) if ( "BadRequestError.__init__() missing 1 required positional argument: 'param'" diff --git a/tests/test_whisper.py b/tests/test_whisper.py index bb097173256..5cb65195186 100644 --- a/tests/test_whisper.py +++ b/tests/test_whisper.py @@ -98,7 +98,7 @@ async def test_transcription_on_router(): "model": "azure/azure-whisper", "api_base": os.getenv("AZURE_EUROPE_API_BASE"), "api_key": os.getenv("AZURE_EUROPE_API_KEY"), - "api_version": os.getenv("2024-02-15-preview"), + "api_version": "2024-02-15-preview", }, }, ] From 2e66267128564d4670b43de43f5b5ce0db6ee6d5 Mon Sep 17 00:00:00 2001 From: Krish Dholakia Date: Fri, 8 Mar 2024 21:47:11 -0800 Subject: [PATCH 6/6] Updated config.yml --- .circleci/config.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.circleci/config.yml b/.circleci/config.yml index daa4d59ec4b..ff6e6e1b27e 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -45,6 +45,7 @@ jobs: pip install "asyncio==3.4.3" pip install "apscheduler==3.10.4" pip install "PyGithub==1.59.1" + pip install python-multipart - save_cache: paths: - ./venv