From 13e0b3f62658e7845ff154865aff83211fea521f Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Fri, 18 Oct 2024 19:14:25 +0530 Subject: [PATCH] (feat) Support `audio`, `modalities` params (#6304) * add audio, modalities param * add test for gpt audio models * add get_supported_openai_params for GPT audio models * add supported params for audio * test_audio_output_from_model * bump openai to openai==1.52.0 * bump openai on pyproject * fix audio test * fix test mock_chat_response * handle audio for Message * fix handling audio for OAI compatible API endpoints * fix linting * fix mock dbrx test --- .circleci/config.yml | 10 +-- .circleci/requirements.txt | 2 +- litellm/__init__.py | 9 +++ .../OpenAI/chat/gpt_audio_transformation.py | 66 +++++++++++++++ .../llms/OpenAI/chat/gpt_transformation.py | 12 +++ litellm/llms/OpenAI/openai.py | 39 +++++++-- litellm/main.py | 18 ++++- litellm/types/llms/openai.py | 2 + litellm/types/utils.py | 62 +++++++++++++- litellm/utils.py | 5 ++ poetry.lock | 8 +- pyproject.toml | 2 +- requirements.txt | 2 +- tests/llm_translation/dog.wav | Bin 0 -> 31278 bytes tests/llm_translation/test_gpt4o_audio.py | 76 ++++++++++++++++++ 15 files changed, 290 insertions(+), 23 deletions(-) create mode 100644 litellm/llms/OpenAI/chat/gpt_audio_transformation.py create mode 100644 tests/llm_translation/dog.wav create mode 100644 tests/llm_translation/test_gpt4o_audio.py diff --git a/.circleci/config.yml b/.circleci/config.yml index 1116ad741..c84f5d941 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -47,7 +47,7 @@ jobs: pip install opentelemetry-api==1.25.0 pip install opentelemetry-sdk==1.25.0 pip install opentelemetry-exporter-otlp==1.25.0 - pip install openai==1.51.0 + pip install openai==1.52.0 pip install prisma==0.11.0 pip install "detect_secrets==1.5.0" pip install "httpx==0.24.1" @@ -517,7 +517,7 @@ jobs: pip install "aiodynamo==23.10.1" pip install "asyncio==3.4.3" pip install "PyGithub==1.59.1" - pip install "openai==1.51.0" + pip install "openai==1.52.0" # Run pytest and generate JUnit XML report - run: name: Build Docker image @@ -634,7 +634,7 @@ jobs: pip install "aiodynamo==23.10.1" pip install "asyncio==3.4.3" pip install "PyGithub==1.59.1" - pip install "openai==1.51.0" + pip install "openai==1.52.0" - run: name: Build Docker image command: docker build -t my-app:latest -f ./docker/Dockerfile.database . @@ -726,7 +726,7 @@ jobs: pip install "pytest-asyncio==0.21.1" pip install "google-cloud-aiplatform==1.43.0" pip install aiohttp - pip install "openai==1.51.0" + pip install "openai==1.52.0" python -m pip install --upgrade pip pip install "pydantic==2.7.1" pip install "pytest==7.3.1" @@ -921,7 +921,7 @@ jobs: pip install "pytest-retry==1.6.3" pip install "pytest-asyncio==0.21.1" pip install aiohttp - pip install "openai==1.51.0" + pip install "openai==1.52.0" python -m pip install --upgrade pip pip install "pydantic==2.7.1" pip install "pytest==7.3.1" diff --git a/.circleci/requirements.txt b/.circleci/requirements.txt index b4e9fcdb3..4912c052c 100644 --- a/.circleci/requirements.txt +++ b/.circleci/requirements.txt @@ -1,5 +1,5 @@ # used by CI/CD testing -openai==1.51.0 +openai==1.52.0 python-dotenv tiktoken importlib_metadata diff --git a/litellm/__init__.py b/litellm/__init__.py index 701d7b23b..35991f421 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -987,10 +987,19 @@ from .llms.mistral.mistral_chat_transformation import MistralConfig from .llms.OpenAI.chat.o1_transformation import ( OpenAIO1Config, ) + +openAIO1Config = OpenAIO1Config() from .llms.OpenAI.chat.gpt_transformation import ( OpenAIGPTConfig, ) +openAIGPTConfig = OpenAIGPTConfig() +from .llms.OpenAI.chat.gpt_audio_transformation import ( + OpenAIGPTAudioConfig, +) + +openAIGPTAudioConfig = OpenAIGPTAudioConfig() + from .llms.nvidia_nim.chat import NvidiaNimConfig from .llms.nvidia_nim.embed import NvidiaNimEmbeddingConfig diff --git a/litellm/llms/OpenAI/chat/gpt_audio_transformation.py b/litellm/llms/OpenAI/chat/gpt_audio_transformation.py new file mode 100644 index 000000000..59f7dc01e --- /dev/null +++ b/litellm/llms/OpenAI/chat/gpt_audio_transformation.py @@ -0,0 +1,66 @@ +""" +Support for GPT-4o audio Family + +OpenAI Doc: https://platform.openai.com/docs/guides/audio/quickstart?audio-generation-quickstart-example=audio-in&lang=python +""" + +import types +from typing import Optional, Union + +import litellm +from litellm.types.llms.openai import AllMessageValues, ChatCompletionUserMessage + +from .gpt_transformation import OpenAIGPTConfig + + +class OpenAIGPTAudioConfig(OpenAIGPTConfig): + """ + Reference: https://platform.openai.com/docs/guides/audio + """ + + @classmethod + def get_config(cls): + return { + k: v + for k, v in cls.__dict__.items() + if not k.startswith("__") + and not isinstance( + v, + ( + types.FunctionType, + types.BuiltinFunctionType, + classmethod, + staticmethod, + ), + ) + and v is not None + } + + def get_supported_openai_params(self, model: str) -> list: + """ + Get the supported OpenAI params for the `gpt-audio` models + + """ + + all_openai_params = super().get_supported_openai_params(model=model) + audio_specific_params = ["audio"] + return all_openai_params + audio_specific_params + + def is_model_gpt_audio_model(self, model: str) -> bool: + if model in litellm.open_ai_chat_completion_models and "audio" in model: + return True + return False + + def _map_openai_params( + self, + non_default_params: dict, + optional_params: dict, + model: str, + drop_params: bool, + ) -> dict: + return super()._map_openai_params( + non_default_params=non_default_params, + optional_params=optional_params, + model=model, + drop_params=drop_params, + ) diff --git a/litellm/llms/OpenAI/chat/gpt_transformation.py b/litellm/llms/OpenAI/chat/gpt_transformation.py index 6331322bf..4eced5b1b 100644 --- a/litellm/llms/OpenAI/chat/gpt_transformation.py +++ b/litellm/llms/OpenAI/chat/gpt_transformation.py @@ -93,6 +93,7 @@ class OpenAIGPTConfig: "top_logprobs", "max_tokens", "max_completion_tokens", + "modalities", "n", "presence_penalty", "seed", @@ -131,6 +132,17 @@ class OpenAIGPTConfig: model: str, drop_params: bool, ) -> dict: + """ + If any supported_openai_params are in non_default_params, add them to optional_params, so they are use in API call + + Args: + non_default_params (dict): Non-default parameters to filter. + optional_params (dict): Optional parameters to update. + model (str): Model name for parameter support check. + + Returns: + dict: Updated optional_params with supported non-default parameters. + """ supported_openai_params = self.get_supported_openai_params(model) for param, value in non_default_params.items(): if param in supported_openai_params: diff --git a/litellm/llms/OpenAI/openai.py b/litellm/llms/OpenAI/openai.py index cb118adca..acf6b3003 100644 --- a/litellm/llms/OpenAI/openai.py +++ b/litellm/llms/OpenAI/openai.py @@ -303,10 +303,25 @@ class OpenAIConfig: } def get_supported_openai_params(self, model: str) -> list: - if litellm.OpenAIO1Config().is_model_o1_reasoning_model(model=model): - return litellm.OpenAIO1Config().get_supported_openai_params(model=model) + """ + This function returns the list of supported openai parameters for a given OpenAI Model + + - If O1 model, returns O1 supported params + - If gpt-audio model, returns gpt-audio supported params + - Else, returns gpt supported params + + Args: + model (str): OpenAI model + + Returns: + list: List of supported openai parameters + """ + if litellm.openAIO1Config.is_model_o1_reasoning_model(model=model): + return litellm.openAIO1Config.get_supported_openai_params(model=model) + elif litellm.openAIGPTAudioConfig.is_model_gpt_audio_model(model=model): + return litellm.openAIGPTAudioConfig.get_supported_openai_params(model=model) else: - return litellm.OpenAIGPTConfig().get_supported_openai_params(model=model) + return litellm.openAIGPTConfig.get_supported_openai_params(model=model) def _map_openai_params( self, non_default_params: dict, optional_params: dict, model: str @@ -325,14 +340,22 @@ class OpenAIConfig: drop_params: bool, ) -> dict: """ """ - if litellm.OpenAIO1Config().is_model_o1_reasoning_model(model=model): - return litellm.OpenAIO1Config().map_openai_params( + if litellm.openAIO1Config.is_model_o1_reasoning_model(model=model): + return litellm.openAIO1Config.map_openai_params( non_default_params=non_default_params, optional_params=optional_params, model=model, drop_params=drop_params, ) - return litellm.OpenAIGPTConfig().map_openai_params( + elif litellm.openAIGPTAudioConfig.is_model_gpt_audio_model(model=model): + return litellm.openAIGPTAudioConfig.map_openai_params( + non_default_params=non_default_params, + optional_params=optional_params, + model=model, + drop_params=drop_params, + ) + + return litellm.openAIGPTConfig.map_openai_params( non_default_params=non_default_params, optional_params=optional_params, model=model, @@ -666,10 +689,10 @@ class OpenAIChatCompletion(BaseLLM): custom_llm_provider=custom_llm_provider, ) if ( - litellm.OpenAIO1Config().is_model_o1_reasoning_model(model=model) + litellm.openAIO1Config.is_model_o1_reasoning_model(model=model) and messages is not None ): - messages = litellm.OpenAIO1Config().o1_prompt_factory( + messages = litellm.openAIO1Config.o1_prompt_factory( messages=messages, ) diff --git a/litellm/main.py b/litellm/main.py index f93db2a8f..9b6d657a3 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -147,6 +147,8 @@ from .llms.vertex_ai_and_google_ai_studio.vertex_embeddings.embedding_handler im from .llms.watsonx import IBMWatsonXAI from .types.llms.openai import ( ChatCompletionAssistantMessage, + ChatCompletionAudioParam, + ChatCompletionModality, ChatCompletionUserMessage, HttpxBinaryResponseContent, ) @@ -287,6 +289,8 @@ async def acompletion( stop=None, max_tokens: Optional[int] = None, max_completion_tokens: Optional[int] = None, + modalities: Optional[List[ChatCompletionModality]] = None, + audio: Optional[ChatCompletionAudioParam] = None, presence_penalty: Optional[float] = None, frequency_penalty: Optional[float] = None, logit_bias: Optional[dict] = None, @@ -327,6 +331,8 @@ async def acompletion( stop(string/list, optional): - Up to 4 sequences where the LLM API will stop generating further tokens. max_tokens (integer, optional): The maximum number of tokens in the generated completion (default is infinity). max_completion_tokens (integer, optional): An upper bound for the number of tokens that can be generated for a completion, including visible output tokens and reasoning tokens. + modalities (List[ChatCompletionModality], optional): Output types that you would like the model to generate for this request. You can use `["text", "audio"]` + audio (ChatCompletionAudioParam, optional): Parameters for audio output. Required when audio output is requested with modalities: ["audio"] presence_penalty (float, optional): It is used to penalize new tokens based on their existence in the text so far. frequency_penalty: It is used to penalize new tokens based on their frequency in the text so far. logit_bias (dict, optional): Used to modify the probability of specific tokens appearing in the completion. @@ -366,6 +372,8 @@ async def acompletion( "stop": stop, "max_tokens": max_tokens, "max_completion_tokens": max_completion_tokens, + "modalities": modalities, + "audio": audio, "presence_penalty": presence_penalty, "frequency_penalty": frequency_penalty, "logit_bias": logit_bias, @@ -670,6 +678,8 @@ def completion( # type: ignore # noqa: PLR0915 stop=None, max_completion_tokens: Optional[int] = None, max_tokens: Optional[int] = None, + modalities: Optional[List[ChatCompletionModality]] = None, + audio: Optional[ChatCompletionAudioParam] = None, presence_penalty: Optional[float] = None, frequency_penalty: Optional[float] = None, logit_bias: Optional[dict] = None, @@ -712,6 +722,8 @@ def completion( # type: ignore # noqa: PLR0915 stop(string/list, optional): - Up to 4 sequences where the LLM API will stop generating further tokens. max_tokens (integer, optional): The maximum number of tokens in the generated completion (default is infinity). max_completion_tokens (integer, optional): An upper bound for the number of tokens that can be generated for a completion, including visible output tokens and reasoning tokens. + modalities (List[ChatCompletionModality], optional): Output types that you would like the model to generate for this request.. You can use `["text", "audio"]` + audio (ChatCompletionAudioParam, optional): Parameters for audio output. Required when audio output is requested with modalities: ["audio"] presence_penalty (float, optional): It is used to penalize new tokens based on their existence in the text so far. frequency_penalty: It is used to penalize new tokens based on their frequency in the text so far. logit_bias (dict, optional): Used to modify the probability of specific tokens appearing in the completion. @@ -816,6 +828,8 @@ def completion( # type: ignore # noqa: PLR0915 "stream_options", "stop", "max_completion_tokens", + "modalities", + "audio", "max_tokens", "presence_penalty", "frequency_penalty", @@ -975,6 +989,8 @@ def completion( # type: ignore # noqa: PLR0915 stop=stop, max_tokens=max_tokens, max_completion_tokens=max_completion_tokens, + modalities=modalities, + audio=audio, presence_penalty=presence_penalty, frequency_penalty=frequency_penalty, logit_bias=logit_bias, @@ -1515,7 +1531,7 @@ def completion( # type: ignore # noqa: PLR0915 ## COMPLETION CALL try: - if litellm.OpenAIO1Config().is_model_o1_reasoning_model(model=model): + if litellm.openAIO1Config.is_model_o1_reasoning_model(model=model): response = openai_o1_chat_completions.completion( model=model, messages=messages, diff --git a/litellm/types/llms/openai.py b/litellm/types/llms/openai.py index 17eb89fd9..0ddf97556 100644 --- a/litellm/types/llms/openai.py +++ b/litellm/types/llms/openai.py @@ -19,6 +19,8 @@ from openai.types.beta.threads.message import Message as OpenAIMessage from openai.types.beta.threads.message_content import MessageContent from openai.types.beta.threads.run import Run from openai.types.chat import ChatCompletionChunk +from openai.types.chat.chat_completion_audio_param import ChatCompletionAudioParam +from openai.types.chat.chat_completion_modality import ChatCompletionModality from openai.types.embedding import Embedding as OpenAIEmbedding from pydantic import BaseModel, Field from typing_extensions import Dict, Required, TypedDict, override diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 409c28458..fce45c336 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -321,6 +321,54 @@ class ChatCompletionMessageToolCall(OpenAIObject): setattr(self, key, value) +class ChatCompletionAudioResponse(OpenAIObject): + def __init__( + self, + data: str, + expires_at: int, + transcript: str, + id: Optional[str] = None, + **params, + ): + super(ChatCompletionAudioResponse, self).__init__(**params) + if id is not None: + self.id = id + else: + self.id = f"{uuid.uuid4()}" + """Unique identifier for this audio response.""" + + self.data = data + """ + Base64 encoded audio bytes generated by the model, in the format specified in + the request. + """ + + self.expires_at = expires_at + """ + The Unix timestamp (in seconds) for when this audio response will no longer be + accessible on the server for use in multi-turn conversations. + """ + + self.transcript = transcript + """Transcript of the audio generated by the model.""" + + def __contains__(self, key): + # Define custom behavior for the 'in' operator + return hasattr(self, key) + + def get(self, key, default=None): + # Custom .get() method to access attributes with a default value if the attribute doesn't exist + return getattr(self, key, default) + + def __getitem__(self, key): + # Allow dictionary-style access to attributes + return getattr(self, key) + + def __setitem__(self, key, value): + # Allow dictionary-style assignment of attributes + setattr(self, key, value) + + """ Reference: ChatCompletionMessage(content='This is a test', role='assistant', function_call=None, tool_calls=None)) @@ -328,11 +376,11 @@ ChatCompletionMessage(content='This is a test', role='assistant', function_call= class Message(OpenAIObject): - content: Optional[str] role: Literal["assistant", "user", "system", "tool", "function"] tool_calls: Optional[List[ChatCompletionMessageToolCall]] function_call: Optional[FunctionCall] + audio: Optional[ChatCompletionAudioResponse] = None def __init__( self, @@ -340,9 +388,10 @@ class Message(OpenAIObject): role: Literal["assistant"] = "assistant", function_call=None, tool_calls: Optional[list] = None, + audio: Optional[ChatCompletionAudioResponse] = None, **params, ): - init_values = { + init_values: Dict[str, Any] = { "content": content, "role": role or "assistant", # handle null input "function_call": ( @@ -361,11 +410,20 @@ class Message(OpenAIObject): else None ), } + + if audio is not None: + init_values["audio"] = audio + super(Message, self).__init__( **init_values, # type: ignore **params, ) + if audio is None: + # delete audio from self + # OpenAI compatible APIs like mistral API will raise an error if audio is passed in + del self.audio + def get(self, key, default=None): # Custom .get() method to access attributes with a default value if the attribute doesn't exist return getattr(self, key, default) diff --git a/litellm/utils.py b/litellm/utils.py index 49f7fe642..de7d528de 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -2483,6 +2483,8 @@ def get_optional_params( # noqa: PLR0915 stop=None, max_tokens=None, max_completion_tokens=None, + modalities=None, + audio=None, presence_penalty=None, frequency_penalty=None, logit_bias=None, @@ -2562,6 +2564,8 @@ def get_optional_params( # noqa: PLR0915 "stop": None, "max_tokens": None, "max_completion_tokens": None, + "modalities": None, + "audio": None, "presence_penalty": None, "frequency_penalty": None, "logit_bias": None, @@ -5734,6 +5738,7 @@ def convert_to_model_response_object( # noqa: PLR0915 role=choice["message"]["role"] or "assistant", function_call=choice["message"].get("function_call", None), tool_calls=tool_calls, + audio=choice["message"].get("audio", None), ) finish_reason = choice.get("finish_reason", None) if finish_reason is None: diff --git a/poetry.lock b/poetry.lock index 42aec43bc..7846ef049 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1823,13 +1823,13 @@ signedtoken = ["cryptography (>=3.0.0)", "pyjwt (>=2.0.0,<3)"] [[package]] name = "openai" -version = "1.51.0" +version = "1.52.0" description = "The official Python library for the openai API" optional = false python-versions = ">=3.7.1" files = [ - {file = "openai-1.51.0-py3-none-any.whl", hash = "sha256:d9affafb7e51e5a27dce78589d4964ce4d6f6d560307265933a94b2e3f3c5d2c"}, - {file = "openai-1.51.0.tar.gz", hash = "sha256:8dc4f9d75ccdd5466fc8c99a952186eddceb9fd6ba694044773f3736a847149d"}, + {file = "openai-1.52.0-py3-none-any.whl", hash = "sha256:0c249f20920183b0a2ca4f7dba7b0452df3ecd0fa7985eb1d91ad884bc3ced9c"}, + {file = "openai-1.52.0.tar.gz", hash = "sha256:95c65a5f77559641ab8f3e4c3a050804f7b51d278870e2ec1f7444080bfe565a"}, ] [package.dependencies] @@ -3519,4 +3519,4 @@ proxy = ["PyJWT", "apscheduler", "backoff", "cryptography", "fastapi", "fastapi- [metadata] lock-version = "2.0" python-versions = ">=3.8.1,<4.0, !=3.9.7" -content-hash = "94beed60a176d854a59b7cf9cace6f7de83ae6036cbcdb8ed10273df5b299afa" +content-hash = "491d361cabc637f8f896091b92855040da670bb7b311dcbfe75ad20eab97400c" diff --git a/pyproject.toml b/pyproject.toml index 0a9f1246a..df07579f5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,7 @@ documentation = "https://docs.litellm.ai" [tool.poetry.dependencies] python = ">=3.8.1,<4.0, !=3.9.7" -openai = ">=1.51.0" +openai = ">=1.52.0" python-dotenv = ">=0.2.0" tiktoken = ">=0.7.0" importlib-metadata = ">=6.8.0" diff --git a/requirements.txt b/requirements.txt index 1cedeeaf7..a08ca5852 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ # LITELLM PROXY DEPENDENCIES # anyio==4.4.0 # openai + http req. -openai==1.51.0 # openai req. +openai==1.52.0 # openai req. fastapi==0.111.0 # server dep backoff==2.2.1 # server dep pyyaml==6.0.0 # server dep diff --git a/tests/llm_translation/dog.wav b/tests/llm_translation/dog.wav new file mode 100644 index 0000000000000000000000000000000000000000..4baa24f3da3f8b56398f8cfdc4babd04be9ed758 GIT binary patch literal 31278 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 S8g~tA`vzFuH;FcWR{cMM3itv5 literal 0 HcmV?d00001 diff --git a/tests/llm_translation/test_gpt4o_audio.py b/tests/llm_translation/test_gpt4o_audio.py new file mode 100644 index 000000000..16128c2fe --- /dev/null +++ b/tests/llm_translation/test_gpt4o_audio.py @@ -0,0 +1,76 @@ +import json +import os +import sys +from datetime import datetime +from unittest.mock import AsyncMock + +sys.path.insert( + 0, os.path.abspath("../..") +) # Adds the parent directory to the system path + + +import httpx +import pytest +from respx import MockRouter + +import litellm +from litellm import Choices, Message, ModelResponse +import base64 +import requests + + +@pytest.mark.asyncio +@pytest.mark.flaky(retries=3, delay=1) +async def test_audio_output_from_model(): + litellm.set_verbose = True + completion = await litellm.acompletion( + model="gpt-4o-audio-preview", + modalities=["text", "audio"], + audio={"voice": "alloy", "format": "wav"}, + messages=[{"role": "user", "content": "response in 1 word - yes or no"}], + ) + + print("response= ", completion) + + print(completion.choices[0]) + + assert completion.choices[0].message.audio is not None + assert isinstance( + completion.choices[0].message.audio, + litellm.types.utils.ChatCompletionAudioResponse, + ) + assert len(completion.choices[0].message.audio.data) > 0 + + wav_bytes = base64.b64decode(completion.choices[0].message.audio.data) + with open("dog.wav", "wb") as f: + f.write(wav_bytes) + + +@pytest.mark.asyncio +async def test_audio_input_to_model(): + # Fetch the audio file and convert it to a base64 encoded string + url = "https://openaiassets.blob.core.windows.net/$web/API/docs/audio/alloy.wav" + response = requests.get(url) + response.raise_for_status() + wav_data = response.content + encoded_string = base64.b64encode(wav_data).decode("utf-8") + + completion = await litellm.acompletion( + model="gpt-4o-audio-preview", + modalities=["text", "audio"], + audio={"voice": "alloy", "format": "wav"}, + messages=[ + { + "role": "user", + "content": [ + {"type": "text", "text": "What is in this recording?"}, + { + "type": "input_audio", + "input_audio": {"data": encoded_string, "format": "wav"}, + }, + ], + }, + ], + ) + + print(completion.choices[0].message)