From 60e634461c5d25405c3303508382fd1a79b53247 Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Tue, 7 Apr 2026 18:07:46 +0530 Subject: [PATCH] feat(containers): Azure container routing, managed IDs, and delete response wire format - Add AzureContainerConfig and safe URL joining for paths with api-version query - Encode/decode managed container IDs in responses, streaming, and proxy handlers - Accept OpenAI delete response object literal container.file.deleted - Tests for Azure URL regression and DeleteContainerFileResponse parsing Made-with: Cursor --- litellm/containers/endpoint_factory.py | 4 +- litellm/containers/main.py | 128 ++++- litellm/llms/azure/containers/__init__.py | 0 .../llms/azure/containers/transformation.py | 48 ++ .../llms/custom_httpx/container_handler.py | 21 +- .../llms/openai/containers/transformation.py | 11 +- litellm/llms/openai/containers/utils.py | 18 + .../container_endpoints/handler_factory.py | 63 ++- litellm/responses/streaming_iterator.py | 44 ++ litellm/responses/utils.py | 241 ++++++++ litellm/types/containers/main.py | 3 +- litellm/utils.py | 6 + .../test_azure_container_transformation.py | 519 ++++++++++++++++++ .../containers/test_container_utils.py | 23 +- 14 files changed, 1078 insertions(+), 51 deletions(-) create mode 100644 litellm/llms/azure/containers/__init__.py create mode 100644 litellm/llms/azure/containers/transformation.py create mode 100644 litellm/llms/openai/containers/utils.py create mode 100644 tests/test_litellm/containers/test_azure_container_transformation.py diff --git a/litellm/containers/endpoint_factory.py b/litellm/containers/endpoint_factory.py index 1d8e50856fe..a0a8f53b280 100644 --- a/litellm/containers/endpoint_factory.py +++ b/litellm/containers/endpoint_factory.py @@ -53,7 +53,7 @@ def create_sync_endpoint_function(endpoint_config: Dict) -> Callable: @client def endpoint_func( timeout: int = 600, - custom_llm_provider: Literal["openai"] = "openai", + custom_llm_provider: Literal["openai", "azure", "azure_text"] = "openai", extra_headers: Optional[Dict[str, Any]] = None, extra_query: Optional[Dict[str, Any]] = None, extra_body: Optional[Dict[str, Any]] = None, @@ -133,7 +133,7 @@ def create_async_endpoint_function( @client async def async_endpoint_func( timeout: int = 600, - custom_llm_provider: Literal["openai"] = "openai", + custom_llm_provider: Literal["openai", "azure", "azure_text"] = "openai", extra_headers: Optional[Dict[str, Any]] = None, extra_query: Optional[Dict[str, Any]] = None, extra_body: Optional[Dict[str, Any]] = None, diff --git a/litellm/containers/main.py b/litellm/containers/main.py index 916fc26351b..196722f5674 100644 --- a/litellm/containers/main.py +++ b/litellm/containers/main.py @@ -40,6 +40,38 @@ __all__ = [ ] +def _decode_container_id_and_update_provider( + container_id: str, + custom_llm_provider: str, + litellm_params: GenericLiteLLMParams, +) -> tuple[str, str, GenericLiteLLMParams]: + """Decode a managed container ID and extract provider/model info. + + Returns: + tuple: (original_container_id, resolved_provider, updated_litellm_params) + """ + from litellm.responses.utils import ResponsesAPIRequestUtils + + # Decode the container ID + decoded = ResponsesAPIRequestUtils._decode_container_id(container_id) + + # Extract the original container ID (what the provider issued) + original_container_id = decoded.get("response_id", container_id) + + # If we decoded provider info, use it (unless explicitly overridden) + decoded_provider = decoded.get("custom_llm_provider") + if decoded_provider and custom_llm_provider == "openai": + # Only override if user didn't explicitly set a different provider + custom_llm_provider = decoded_provider + + # If we decoded model_id, add it to litellm_params for routing + decoded_model_id = decoded.get("model_id") + if decoded_model_id and not litellm_params.get("model_id"): + litellm_params["model_id"] = decoded_model_id + + return original_container_id, custom_llm_provider, litellm_params + + ##### Container Create ####################### @client async def acreate_container( @@ -48,7 +80,7 @@ async def acreate_container( file_ids: Optional[List[str]] = None, timeout=600, # default to 10 minutes # LiteLLM specific params, - custom_llm_provider: Literal["openai"] = "openai", + custom_llm_provider: Literal["openai", "azure", "azure_text"] = "openai", # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Optional[Dict[str, Any]] = None, @@ -122,7 +154,7 @@ def create_container( api_key: Optional[str] = None, api_base: Optional[str] = None, api_version: Optional[str] = None, - custom_llm_provider: Literal["openai"] = "openai", + custom_llm_provider: Literal["openai", "azure", "azure_text"] = "openai", *, acreate_container: Literal[True], **kwargs, @@ -139,7 +171,7 @@ def create_container( api_key: Optional[str] = None, api_base: Optional[str] = None, api_version: Optional[str] = None, - custom_llm_provider: Literal["openai"] = "openai", + custom_llm_provider: Literal["openai", "azure", "azure_text"] = "openai", *, acreate_container: Literal[False] = False, **kwargs, @@ -158,7 +190,7 @@ def create_container( api_key: Optional[str] = None, api_base: Optional[str] = None, api_version: Optional[str] = None, - custom_llm_provider: Literal["openai"] = "openai", + custom_llm_provider: Literal["openai", "azure", "azure_text"] = "openai", # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Optional[Dict[str, Any]] = None, @@ -275,7 +307,7 @@ async def alist_containers( limit: Optional[int] = None, order: Optional[str] = None, timeout=600, # default to 10 minutes - custom_llm_provider: Literal["openai"] = "openai", + custom_llm_provider: Literal["openai", "azure", "azure_text"] = "openai", # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Optional[Dict[str, Any]] = None, @@ -348,7 +380,7 @@ def list_containers( api_key: Optional[str] = None, api_base: Optional[str] = None, api_version: Optional[str] = None, - custom_llm_provider: Literal["openai"] = "openai", + custom_llm_provider: Literal["openai", "azure", "azure_text"] = "openai", *, alist_containers: Literal[True], **kwargs, @@ -365,7 +397,7 @@ def list_containers( api_key: Optional[str] = None, api_base: Optional[str] = None, api_version: Optional[str] = None, - custom_llm_provider: Literal["openai"] = "openai", + custom_llm_provider: Literal["openai", "azure", "azure_text"] = "openai", *, alist_containers: Literal[False] = False, **kwargs, @@ -384,7 +416,7 @@ def list_containers( api_key: Optional[str] = None, api_base: Optional[str] = None, api_version: Optional[str] = None, - custom_llm_provider: Literal["openai"] = "openai", + custom_llm_provider: Literal["openai", "azure", "azure_text"] = "openai", # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Optional[Dict[str, Any]] = None, @@ -481,7 +513,7 @@ def list_containers( async def aretrieve_container( container_id: str, timeout=600, # default to 10 minutes - custom_llm_provider: Literal["openai"] = "openai", + custom_llm_provider: Literal["openai", "azure", "azure_text"] = "openai", # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Optional[Dict[str, Any]] = None, @@ -548,7 +580,7 @@ def retrieve_container( api_key: Optional[str] = None, api_base: Optional[str] = None, api_version: Optional[str] = None, - custom_llm_provider: Literal["openai"] = "openai", + custom_llm_provider: Literal["openai", "azure", "azure_text"] = "openai", *, aretrieve_container: Literal[True], **kwargs, @@ -563,7 +595,7 @@ def retrieve_container( api_key: Optional[str] = None, api_base: Optional[str] = None, api_version: Optional[str] = None, - custom_llm_provider: Literal["openai"] = "openai", + custom_llm_provider: Literal["openai", "azure", "azure_text"] = "openai", *, aretrieve_container: Literal[False] = False, **kwargs, @@ -580,7 +612,7 @@ def retrieve_container( api_key: Optional[str] = None, api_base: Optional[str] = None, api_version: Optional[str] = None, - custom_llm_provider: Literal["openai"] = "openai", + custom_llm_provider: Literal["openai", "azure", "azure_text"] = "openai", # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Optional[Dict[str, Any]] = None, @@ -615,6 +647,16 @@ def retrieve_container( api_version=api_version, **kwargs, ) + + # Decode container ID and extract provider info + original_container_id, custom_llm_provider, litellm_params = ( + _decode_container_id_and_update_provider( + container_id=container_id, + custom_llm_provider=custom_llm_provider, + litellm_params=litellm_params, + ) + ) + # get provider config container_provider_config: Optional[ BaseContainerConfig @@ -642,7 +684,7 @@ def retrieve_container( litellm_logging_obj.call_type = CallTypes.retrieve_container.value return base_llm_http_handler.container_retrieve_handler( - container_id=container_id, + container_id=original_container_id, # Use decoded original ID container_provider_config=container_provider_config, litellm_params=litellm_params, logging_obj=litellm_logging_obj, @@ -667,7 +709,7 @@ def retrieve_container( async def adelete_container( container_id: str, timeout=600, # default to 10 minutes - custom_llm_provider: Literal["openai"] = "openai", + custom_llm_provider: Literal["openai", "azure", "azure_text"] = "openai", # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Optional[Dict[str, Any]] = None, @@ -734,7 +776,7 @@ def delete_container( api_key: Optional[str] = None, api_base: Optional[str] = None, api_version: Optional[str] = None, - custom_llm_provider: Literal["openai"] = "openai", + custom_llm_provider: Literal["openai", "azure", "azure_text"] = "openai", *, adelete_container: Literal[True], **kwargs, @@ -749,7 +791,7 @@ def delete_container( api_key: Optional[str] = None, api_base: Optional[str] = None, api_version: Optional[str] = None, - custom_llm_provider: Literal["openai"] = "openai", + custom_llm_provider: Literal["openai", "azure", "azure_text"] = "openai", *, adelete_container: Literal[False] = False, **kwargs, @@ -766,7 +808,7 @@ def delete_container( api_key: Optional[str] = None, api_base: Optional[str] = None, api_version: Optional[str] = None, - custom_llm_provider: Literal["openai"] = "openai", + custom_llm_provider: Literal["openai", "azure", "azure_text"] = "openai", # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Optional[Dict[str, Any]] = None, @@ -801,6 +843,16 @@ def delete_container( api_version=api_version, **kwargs, ) + + # Decode container ID and extract provider info + original_container_id, custom_llm_provider, litellm_params = ( + _decode_container_id_and_update_provider( + container_id=container_id, + custom_llm_provider=custom_llm_provider, + litellm_params=litellm_params, + ) + ) + # get provider config container_provider_config: Optional[ BaseContainerConfig @@ -828,7 +880,7 @@ def delete_container( litellm_logging_obj.call_type = CallTypes.delete_container.value return base_llm_http_handler.container_delete_handler( - container_id=container_id, + container_id=original_container_id, # Use decoded original ID container_provider_config=container_provider_config, litellm_params=litellm_params, logging_obj=litellm_logging_obj, @@ -856,7 +908,7 @@ async def alist_container_files( limit: Optional[int] = None, order: Optional[str] = None, timeout=600, # default to 10 minutes - custom_llm_provider: Literal["openai"] = "openai", + custom_llm_provider: Literal["openai", "azure", "azure_text"] = "openai", extra_headers: Optional[Dict[str, Any]] = None, extra_query: Optional[Dict[str, Any]] = None, extra_body: Optional[Dict[str, Any]] = None, @@ -930,7 +982,7 @@ def list_container_files( api_key: Optional[str] = None, api_base: Optional[str] = None, api_version: Optional[str] = None, - custom_llm_provider: Literal["openai"] = "openai", + custom_llm_provider: Literal["openai", "azure", "azure_text"] = "openai", *, alist_container_files: Literal[True], **kwargs, @@ -948,7 +1000,7 @@ def list_container_files( api_key: Optional[str] = None, api_base: Optional[str] = None, api_version: Optional[str] = None, - custom_llm_provider: Literal["openai"] = "openai", + custom_llm_provider: Literal["openai", "azure", "azure_text"] = "openai", *, alist_container_files: Literal[False] = False, **kwargs, @@ -968,7 +1020,7 @@ def list_container_files( api_key: Optional[str] = None, api_base: Optional[str] = None, api_version: Optional[str] = None, - custom_llm_provider: Literal["openai"] = "openai", + custom_llm_provider: Literal["openai", "azure", "azure_text"] = "openai", extra_headers: Optional[Dict[str, Any]] = None, extra_query: Optional[Dict[str, Any]] = None, extra_body: Optional[Dict[str, Any]] = None, @@ -1001,6 +1053,16 @@ def list_container_files( api_version=api_version, **kwargs, ) + + # Decode container ID and extract provider info + original_container_id, custom_llm_provider, litellm_params = ( + _decode_container_id_and_update_provider( + container_id=container_id, + custom_llm_provider=custom_llm_provider, + litellm_params=litellm_params, + ) + ) + # get provider config container_provider_config: Optional[ BaseContainerConfig @@ -1033,7 +1095,7 @@ def list_container_files( litellm_logging_obj.call_type = CallTypes.list_container_files.value return base_llm_http_handler.container_file_list_handler( - container_id=container_id, + container_id=original_container_id, # Use decoded original ID container_provider_config=container_provider_config, litellm_params=litellm_params, logging_obj=litellm_logging_obj, @@ -1062,7 +1124,7 @@ async def aupload_container_file( container_id: str, file: FileTypes, timeout=600, # default to 10 minutes - custom_llm_provider: Literal["openai"] = "openai", + custom_llm_provider: Literal["openai", "azure", "azure_text"] = "openai", extra_headers: Optional[Dict[str, Any]] = None, extra_query: Optional[Dict[str, Any]] = None, extra_body: Optional[Dict[str, Any]] = None, @@ -1151,7 +1213,7 @@ def upload_container_file( api_key: Optional[str] = None, api_base: Optional[str] = None, api_version: Optional[str] = None, - custom_llm_provider: Literal["openai"] = "openai", + custom_llm_provider: Literal["openai", "azure", "azure_text"] = "openai", *, aupload_container_file: Literal[True], **kwargs, @@ -1167,7 +1229,7 @@ def upload_container_file( api_key: Optional[str] = None, api_base: Optional[str] = None, api_version: Optional[str] = None, - custom_llm_provider: Literal["openai"] = "openai", + custom_llm_provider: Literal["openai", "azure", "azure_text"] = "openai", *, aupload_container_file: Literal[False] = False, **kwargs, @@ -1185,7 +1247,7 @@ def upload_container_file( api_key: Optional[str] = None, api_base: Optional[str] = None, api_version: Optional[str] = None, - custom_llm_provider: Literal["openai"] = "openai", + custom_llm_provider: Literal["openai", "azure", "azure_text"] = "openai", extra_headers: Optional[Dict[str, Any]] = None, extra_query: Optional[Dict[str, Any]] = None, extra_body: Optional[Dict[str, Any]] = None, @@ -1247,6 +1309,16 @@ def upload_container_file( api_version=api_version, **kwargs, ) + + # Decode container ID and extract provider info + original_container_id, custom_llm_provider, litellm_params = ( + _decode_container_id_and_update_provider( + container_id=container_id, + custom_llm_provider=custom_llm_provider, + litellm_params=litellm_params, + ) + ) + # get provider config container_provider_config: Optional[ BaseContainerConfig @@ -1282,7 +1354,7 @@ def upload_container_file( extra_query=extra_query, timeout=timeout or DEFAULT_REQUEST_TIMEOUT, _is_async=_is_async, - container_id=container_id, + container_id=original_container_id, # Use decoded original ID file=file, ) diff --git a/litellm/llms/azure/containers/__init__.py b/litellm/llms/azure/containers/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/litellm/llms/azure/containers/transformation.py b/litellm/llms/azure/containers/transformation.py new file mode 100644 index 00000000000..586b2e379a0 --- /dev/null +++ b/litellm/llms/azure/containers/transformation.py @@ -0,0 +1,48 @@ +from typing import Optional + +from litellm.llms.azure.common_utils import BaseAzureLLM +from litellm.llms.openai.containers.transformation import OpenAIContainerConfig +from litellm.types.router import GenericLiteLLMParams + + +class AzureContainerConfig(OpenAIContainerConfig): + """ + Configuration class for Azure OpenAI container API. + + Inherits request/response transformations from OpenAIContainerConfig since + Azure's container API is wire-compatible with OpenAI's. Only overrides + authentication (api-key header) and URL construction (openai/v1/containers path). + + Azure container API reference: + https://learn.microsoft.com/en-us/azure/foundry/openai/latest#containers + """ + + def validate_environment( + self, + headers: dict, + api_key: Optional[str] = None, + ) -> dict: + return BaseAzureLLM._base_validate_azure_environment( + headers=headers, + litellm_params=GenericLiteLLMParams(api_key=api_key), + ) + + def get_complete_url( + self, + api_base: Optional[str], + litellm_params: dict, + ) -> str: + """ + Build the Azure container endpoint URL. + + Azure container API uses the path: + {endpoint}/openai/v1/containers + when api_version is 'v1', 'latest', or 'preview'; otherwise: + {endpoint}/openai/containers + """ + return BaseAzureLLM._get_base_azure_url( + api_base=api_base, + litellm_params=litellm_params, + route="/openai/containers", + default_api_version="v1", + ) diff --git a/litellm/llms/custom_httpx/container_handler.py b/litellm/llms/custom_httpx/container_handler.py index 3767949375d..2d54f33bf96 100644 --- a/litellm/llms/custom_httpx/container_handler.py +++ b/litellm/llms/custom_httpx/container_handler.py @@ -61,18 +61,29 @@ def _build_url( ) -> str: """Build the full URL by substituting path parameters. - The api_base from get_complete_url already includes /containers, - so we need to strip that prefix from the path_template. + The api_base from get_complete_url already includes /containers and may include + query parameters. We need to parse the URL, append the path, then preserve the + query parameters. """ # api_base ends with /containers, path_template starts with /containers # So we need to strip /containers from the path if path_template.startswith("/containers"): path_template = path_template[len("/containers") :] - url = f"{api_base.rstrip('/')}{path_template}" + # Substitute path parameters for param, value in path_params.items(): - url = url.replace(f"{{{param}}}", value) - return url + path_template = path_template.replace(f"{{{param}}}", value) + + # Parse the api_base to extract existing query params + parsed_base = httpx.URL(api_base) + + # Append the path to the existing path (before query params) + new_path = f"{parsed_base.path.rstrip('/')}{path_template}" + + # Rebuild URL with new path, preserving query params + final_url = parsed_base.copy_with(path=new_path) + + return str(final_url) def _build_query_params( diff --git a/litellm/llms/openai/containers/transformation.py b/litellm/llms/openai/containers/transformation.py index 645538fdd9c..955b9f760d1 100644 --- a/litellm/llms/openai/containers/transformation.py +++ b/litellm/llms/openai/containers/transformation.py @@ -17,6 +17,7 @@ from litellm.types.containers.main import ( from litellm.types.router import GenericLiteLLMParams from ...base_llm.containers.transformation import BaseContainerConfig +from .utils import join_container_api_base_path if TYPE_CHECKING: from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj @@ -197,7 +198,7 @@ class OpenAIContainerConfig(BaseContainerConfig): ) -> Tuple[str, Dict]: """Transform the OpenAI container retrieve request.""" # For container retrieve, we just need to construct the URL - url = f"{api_base.rstrip('/')}/{container_id}" + url = join_container_api_base_path(api_base, f"/{container_id}") # No additional data needed for GET request data: Dict[str, Any] = {} @@ -229,7 +230,7 @@ class OpenAIContainerConfig(BaseContainerConfig): - DELETE /v1/containers/{container_id} """ # Construct the URL for container delete - url = f"{api_base.rstrip('/')}/{container_id}" + url = join_container_api_base_path(api_base, f"/{container_id}") # No data needed for DELETE request data: Dict[str, Any] = {} @@ -266,7 +267,7 @@ class OpenAIContainerConfig(BaseContainerConfig): - GET /v1/containers/{container_id}/files """ # Construct the URL for container files - url = f"{api_base.rstrip('/')}/{container_id}/files" + url = join_container_api_base_path(api_base, f"/{container_id}/files") # Prepare query parameters params: Dict[str, Any] = {} @@ -310,7 +311,9 @@ class OpenAIContainerConfig(BaseContainerConfig): - GET /v1/containers/{container_id}/files/{file_id}/content """ # Construct the URL for container file content - url = f"{api_base.rstrip('/')}/{container_id}/files/{file_id}/content" + url = join_container_api_base_path( + api_base, f"/{container_id}/files/{file_id}/content" + ) # No query parameters needed params: Dict[str, Any] = {} diff --git a/litellm/llms/openai/containers/utils.py b/litellm/llms/openai/containers/utils.py new file mode 100644 index 00000000000..c4ac35a2f85 --- /dev/null +++ b/litellm/llms/openai/containers/utils.py @@ -0,0 +1,18 @@ +"""Shared helpers for OpenAI-compatible container API URL construction.""" + +import httpx + + +def join_container_api_base_path(api_base: str, path_suffix: str) -> str: + """Append ``path_suffix`` to the path of ``api_base``, keeping the query string last. + + Azure (and some bases) pass ``api_base`` like + ``https://host/openai/v1/containers?api-version=v1``. Naive string concat would + produce ``...?api-version=v1/cntr_...`` which is invalid; this uses ``httpx.URL`` + so the result is ``.../containers/cntr_.../files?api-version=v1``. + """ + if not path_suffix.startswith("/"): + path_suffix = f"/{path_suffix}" + parsed = httpx.URL(api_base) + new_path = f"{parsed.path.rstrip('/')}{path_suffix}" + return str(parsed.copy_with(path=new_path)) diff --git a/litellm/proxy/container_endpoints/handler_factory.py b/litellm/proxy/container_endpoints/handler_factory.py index 078f0c9bc49..badada9439e 100644 --- a/litellm/proxy/container_endpoints/handler_factory.py +++ b/litellm/proxy/container_endpoints/handler_factory.py @@ -40,10 +40,13 @@ def _get_container_provider_config(custom_llm_provider: str): from litellm.llms.openai.containers.transformation import OpenAIContainerConfig return OpenAIContainerConfig() - else: - raise ValueError( - f"Container API not supported for provider: {custom_llm_provider}" - ) + elif custom_llm_provider in ("azure", "azure_text"): + from litellm.llms.azure.containers.transformation import AzureContainerConfig + + return AzureContainerConfig() + raise ValueError( + f"Container API not supported for provider: {custom_llm_provider}" + ) def _create_handler_for_path_params( @@ -171,12 +174,23 @@ async def _process_binary_request( or "openai" ) - # Get the provider config - container_provider_config = _get_container_provider_config(custom_llm_provider) - # Build litellm_params - credentials are resolved by provider config from env litellm_params = GenericLiteLLMParams() + # Decode container ID and extract provider info + from litellm.responses.utils import ResponsesAPIRequestUtils + + decoded = ResponsesAPIRequestUtils._decode_container_id(container_id) + original_container_id = decoded.get("response_id", container_id) + + # If container ID has encoded provider info and user didn't explicitly set provider, use it + decoded_provider = decoded.get("custom_llm_provider") + if decoded_provider and custom_llm_provider == "openai": + custom_llm_provider = decoded_provider + + # Get the provider config + container_provider_config = _get_container_provider_config(custom_llm_provider) + # Create logging object logging_obj = Logging( model="container-file-content", @@ -193,7 +207,7 @@ async def _process_binary_request( try: content = await handler.async_container_file_content_handler( - container_id=container_id, + container_id=original_container_id, # Use decoded original ID file_id=file_id, container_provider_config=container_provider_config, litellm_params=litellm_params, @@ -267,13 +281,24 @@ async def _process_multipart_upload_request( if isinstance(file_list, list) and len(file_list) > 0: data["file"] = file_list[0] - data["container_id"] = container_id - custom_llm_provider = ( get_custom_llm_provider_from_request_headers(request=request) or get_custom_llm_provider_from_request_query(request=request) or "openai" ) + + # Decode container ID and extract provider info + from litellm.responses.utils import ResponsesAPIRequestUtils + + decoded = ResponsesAPIRequestUtils._decode_container_id(container_id) + original_container_id = decoded.get("response_id", container_id) + + # If container ID has encoded provider info and user didn't explicitly set provider, use it + decoded_provider = decoded.get("custom_llm_provider") + if decoded_provider and custom_llm_provider == "openai": + custom_llm_provider = decoded_provider + + data["container_id"] = original_container_id # Use decoded original ID data["custom_llm_provider"] = custom_llm_provider processor = ProxyBaseLLMRequestProcessing(data=data) @@ -338,6 +363,24 @@ async def _process_request( or get_custom_llm_provider_from_request_query(request=request) or "openai" ) + + # Decode container_id if present in path_params + if "container_id" in path_params: + from litellm.responses.utils import ResponsesAPIRequestUtils + + decoded = ResponsesAPIRequestUtils._decode_container_id( + path_params["container_id"] + ) + original_container_id = decoded.get("response_id", path_params["container_id"]) + + # If container ID has encoded provider info and user didn't explicitly set provider, use it + decoded_provider = decoded.get("custom_llm_provider") + if decoded_provider and custom_llm_provider == "openai": + custom_llm_provider = decoded_provider + + # Update path_params with decoded original ID + data["container_id"] = original_container_id + data["custom_llm_provider"] = custom_llm_provider processor = ProxyBaseLLMRequestProcessing(data=data) diff --git a/litellm/responses/streaming_iterator.py b/litellm/responses/streaming_iterator.py index 10a74a5b3c6..7d86c211ac0 100644 --- a/litellm/responses/streaming_iterator.py +++ b/litellm/responses/streaming_iterator.py @@ -140,6 +140,50 @@ class BaseResponsesAPIStreamingIterator: ) setattr(openai_responses_api_chunk, "response", response) + # Encode container_id on streaming events so proxy/UI follow-ups route correctly + _event_type = getattr(openai_responses_api_chunk, "type", None) + _stream_model_id = ( + self.litellm_metadata.get("model_info", {}).get("id") + if self.litellm_metadata + else None + ) + if _event_type in ( + ResponsesAPIStreamEvents.OUTPUT_ITEM_ADDED, + ResponsesAPIStreamEvents.OUTPUT_ITEM_DONE, + ): + _item = getattr(openai_responses_api_chunk, "item", None) + if _item is not None: + ResponsesAPIRequestUtils._encode_container_id_on_output_item( + item=_item, + custom_llm_provider=self.custom_llm_provider, + model_id=_stream_model_id, + ) + elif _event_type == ResponsesAPIStreamEvents.OUTPUT_TEXT_ANNOTATION_ADDED: + _annotation = getattr( + openai_responses_api_chunk, "annotation", None + ) + if _annotation is not None: + ResponsesAPIRequestUtils._encode_container_id_on_output_item( + item=_annotation, + custom_llm_provider=self.custom_llm_provider, + model_id=_stream_model_id, + ) + elif _event_type == ResponsesAPIStreamEvents.CONTENT_PART_DONE: + _part = getattr(openai_responses_api_chunk, "part", None) + if _part is not None: + if isinstance(_part, dict): + ResponsesAPIRequestUtils._encode_container_ids_in_annotations( + _part.get("annotations"), + self.custom_llm_provider, + _stream_model_id, + ) + else: + ResponsesAPIRequestUtils._encode_container_ids_in_annotations( + getattr(_part, "annotations", None), + self.custom_llm_provider, + _stream_model_id, + ) + # Wrap encrypted_content in streaming events (output_item.added, output_item.done) if self.litellm_metadata and self.litellm_metadata.get( "encrypted_content_affinity_enabled" diff --git a/litellm/responses/utils.py b/litellm/responses/utils.py index 11097864225..a617b7e2e07 100644 --- a/litellm/responses/utils.py +++ b/litellm/responses/utils.py @@ -1,4 +1,5 @@ import base64 +import re from typing import ( Any, Dict, @@ -226,6 +227,15 @@ class ResponsesAPIRequestUtils: ) ) + # Encode container IDs in the response output + responses_api_response = ( + ResponsesAPIRequestUtils._update_container_ids_in_response( + responses_api_response=responses_api_response, + custom_llm_provider=custom_llm_provider, + litellm_metadata=litellm_metadata, + ) + ) + return responses_api_response @staticmethod @@ -522,6 +532,237 @@ class ResponsesAPIRequestUtils: ) return decoded_response_id.get("response_id", previous_response_id) + @staticmethod + def _build_container_id( + custom_llm_provider: Optional[str], + model_id: Optional[str], + container_id: str, + ) -> str: + """Build a managed container ID with provider and model info encoded. + + Format: cntr_{base64("litellm:custom_llm_provider:{provider};model_id:{model};container_id:{original}")} + """ + assembled_id = f"litellm:custom_llm_provider:{custom_llm_provider};model_id:{model_id};container_id:{container_id}" + base64_encoded_id = base64.b64encode(assembled_id.encode("utf-8")).decode("utf-8") + return f"cntr_{base64_encoded_id}" + + @staticmethod + def _decode_container_id(container_id: str) -> DecodedResponseId: + """Decode a managed container ID to extract provider, model, and original container ID. + + Returns: + DecodedResponseId with custom_llm_provider, model_id, and response_id (original container_id) + """ + try: + # If it doesn't start with cntr_, it's not a managed ID + if not container_id.startswith("cntr_"): + return DecodedResponseId( + custom_llm_provider=None, + model_id=None, + response_id=container_id, + ) + + # Remove prefix and decode + cleaned_id = container_id.replace("cntr_", "") + decoded_id = base64.b64decode(cleaned_id.encode("utf-8")).decode("utf-8") + + # Parse components using regex to handle semicolons in the container_id + if not decoded_id.startswith("litellm:"): + return DecodedResponseId( + custom_llm_provider=None, + model_id=None, + response_id=container_id, + ) + + # Use regex to extract the three parts, allowing semicolons in container_id + # Format: litellm:custom_llm_provider:{provider};model_id:{model};container_id:{container} + pattern = r"^litellm:custom_llm_provider:([^;]+);model_id:([^;]+);container_id:(.+)$" + match = re.match(pattern, decoded_id) + + if not match: + return DecodedResponseId( + custom_llm_provider=None, + model_id=None, + response_id=container_id, + ) + + custom_llm_provider = match.group(1) + model_id = match.group(2) + original_container_id = match.group(3) + + return DecodedResponseId( + custom_llm_provider=custom_llm_provider, + model_id=model_id, + response_id=original_container_id, + ) + except Exception as e: + verbose_logger.debug(f"Error decoding container_id '{container_id}': {e}") + return DecodedResponseId( + custom_llm_provider=None, + model_id=None, + response_id=container_id, + ) + + @staticmethod + def decode_container_id_to_original(container_id: str) -> str: + """Decode a managed container ID to get the original provider-issued ID. + + This is used when making upstream API calls - we need to send the original + container ID that the provider issued, not our encoded version. + """ + decoded = ResponsesAPIRequestUtils._decode_container_id(container_id) + return decoded.get("response_id", container_id) + + @staticmethod + def _encode_container_ids_in_annotations( + annotations: Any, + custom_llm_provider: Optional[str], + model_id: Optional[str], + ) -> None: + """Encode ``container_id`` on each annotation (e.g. ``container_file_citation``).""" + if not annotations or not isinstance(annotations, list): + return + for ann in annotations: + ResponsesAPIRequestUtils._encode_container_id_on_output_item( + ann, + custom_llm_provider, + model_id, + ) + + @staticmethod + def _encode_container_ids_in_message_content( + content: Any, + custom_llm_provider: Optional[str], + model_id: Optional[str], + ) -> None: + """Walk message ``content`` parts and encode citation ``container_id`` values.""" + if not content: + return + if isinstance(content, list): + for part in content: + if isinstance(part, dict): + ResponsesAPIRequestUtils._encode_container_ids_in_annotations( + part.get("annotations"), + custom_llm_provider, + model_id, + ) + else: + ResponsesAPIRequestUtils._encode_container_ids_in_annotations( + getattr(part, "annotations", None), + custom_llm_provider, + model_id, + ) + + @staticmethod + def _encode_container_id_on_output_item( + item: Any, + custom_llm_provider: Optional[str], + model_id: Optional[str], + ) -> None: + """Mutate one output item (dict or object): wrap raw ``container_id`` as LiteLLM-managed. + + Handles top-level ``container_id`` and nested ``code_interpreter_call.container_id`` + (some wire payloads nest the tool call). Used by non-streaming responses and by + streaming ``response.output_item.*`` events so UIs see managed IDs incrementally. + + For ``message`` items, also encodes ``container_id`` inside + ``content[].annotations`` (``container_file_citation``), which is what clients use + to fetch generated files. + """ + if item is None: + return + + def _maybe_encode(container_id: str) -> Optional[str]: + decoded = ResponsesAPIRequestUtils._decode_container_id(container_id) + if decoded.get("custom_llm_provider") is not None: + return None + return ResponsesAPIRequestUtils._build_container_id( + custom_llm_provider=custom_llm_provider, + model_id=model_id, + container_id=container_id, + ) + + if isinstance(item, dict): + cid = item.get("container_id") + if isinstance(cid, str): + enc = _maybe_encode(cid) + if enc is not None: + item["container_id"] = enc + nested = item.get("code_interpreter_call") + if isinstance(nested, dict): + nc = nested.get("container_id") + if isinstance(nc, str): + enc = _maybe_encode(nc) + if enc is not None: + nested["container_id"] = enc + if item.get("type") == "message": + ResponsesAPIRequestUtils._encode_container_ids_in_message_content( + item.get("content"), + custom_llm_provider, + model_id, + ) + return + + cid_attr = getattr(item, "container_id", None) + if isinstance(cid_attr, str): + enc = _maybe_encode(cid_attr) + if enc is not None: + try: + setattr(item, "container_id", enc) + except Exception: + verbose_logger.debug( + "Could not set container_id on streaming output item", + exc_info=True, + ) + + nested_obj = getattr(item, "code_interpreter_call", None) + if nested_obj is not None: + ResponsesAPIRequestUtils._encode_container_id_on_output_item( + nested_obj, + custom_llm_provider, + model_id, + ) + + if getattr(item, "type", None) == "message": + ResponsesAPIRequestUtils._encode_container_ids_in_message_content( + getattr(item, "content", None), + custom_llm_provider, + model_id, + ) + + @staticmethod + def _update_container_ids_in_response( + responses_api_response: Union[ResponsesAPIResponse, Dict[str, Any]], + custom_llm_provider: Optional[str], + litellm_metadata: Optional[Dict[str, Any]] = None, + ) -> Union[ResponsesAPIResponse, Dict[str, Any]]: + """Encode container IDs in the response output with provider/model info. + + This walks through all output items and encodes any container_id fields + so that follow-up container API calls can auto-route to the correct provider. + """ + litellm_metadata = litellm_metadata or {} + model_info: Dict[str, Any] = litellm_metadata.get("model_info", {}) or {} + model_id = model_info.get("id") + + # Get the output list + if isinstance(responses_api_response, dict): + output = responses_api_response.get("output", []) + else: + output = getattr(responses_api_response, "output", []) + + if not output: + return responses_api_response + + for item in output: + ResponsesAPIRequestUtils._encode_container_id_on_output_item( + item=item, + custom_llm_provider=custom_llm_provider, + model_id=model_id, + ) + + return responses_api_response + @staticmethod def convert_text_format_to_text_param( text_format: Optional[Union[Type["BaseModel"], dict]], diff --git a/litellm/types/containers/main.py b/litellm/types/containers/main.py index df8c05a74c6..0b0bef39e18 100644 --- a/litellm/types/containers/main.py +++ b/litellm/types/containers/main.py @@ -187,7 +187,8 @@ class DeleteContainerFileResponse(BaseModel): """Response object for delete container file request.""" id: str - object: Literal["container_file.deleted"] + # OpenAI / Azure wire format uses dots; keep underscore variant for compatibility. + object: Literal["container.file.deleted", "container_file.deleted"] deleted: bool def __contains__(self, key): diff --git a/litellm/utils.py b/litellm/utils.py index f902644e760..970ca0ec8e6 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -8955,6 +8955,12 @@ class ProviderConfigManager: ) return OpenAIContainerConfig() + if provider in (LlmProviders.AZURE, LlmProviders.AZURE_TEXT): + from litellm.llms.azure.containers.transformation import ( + AzureContainerConfig, + ) + + return AzureContainerConfig() return None @staticmethod diff --git a/tests/test_litellm/containers/test_azure_container_transformation.py b/tests/test_litellm/containers/test_azure_container_transformation.py new file mode 100644 index 00000000000..de79557ea03 --- /dev/null +++ b/tests/test_litellm/containers/test_azure_container_transformation.py @@ -0,0 +1,519 @@ +import os +import sys +from unittest.mock import MagicMock +from urllib.parse import parse_qs, urlparse + +import httpx +import pytest + +sys.path.insert(0, os.path.abspath("../../../")) + +import litellm +from litellm.llms.azure.containers.transformation import AzureContainerConfig +from litellm.llms.base_llm.containers.transformation import BaseContainerConfig +from litellm.types.containers.main import ( + ContainerFileListResponse, + ContainerListResponse, + ContainerObject, + DeleteContainerResult, +) +from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLogging + + +class TestAzureContainerConfig: + """Test suite for Azure container transformation functionality.""" + + def setup_method(self): + self.config = AzureContainerConfig() + self.logging_obj = LiteLLMLogging( + model="", + messages=[], + stream=False, + call_type="create_container", + start_time=None, + litellm_call_id="test_call_id", + function_id="test_function_id", + ) + + def test_inherits_base_container_config(self): + assert isinstance(self.config, BaseContainerConfig) + + def test_get_supported_openai_params(self): + supported_params = self.config.get_supported_openai_params() + assert "name" in supported_params + assert "expires_after" in supported_params + assert "file_ids" in supported_params + + def test_validate_environment_with_api_key(self): + headers = {} + api_key = "test-azure-key" + + validated_headers = self.config.validate_environment( + headers=headers, api_key=api_key + ) + + assert "api-key" in validated_headers + assert validated_headers["api-key"] == api_key + + def test_validate_environment_uses_azure_env_var(self, monkeypatch): + monkeypatch.setenv("AZURE_API_KEY", "env-azure-key") + headers = {} + + validated_headers = self.config.validate_environment(headers=headers) + + assert "api-key" in validated_headers + assert validated_headers["api-key"] == "env-azure-key" + + def test_validate_environment_no_bearer_token(self): + """Azure uses api-key header, not Authorization: Bearer.""" + headers = {} + api_key = "azure-test-key" + + validated_headers = self.config.validate_environment( + headers=headers, api_key=api_key + ) + + assert "Authorization" not in validated_headers + assert "api-key" in validated_headers + + def test_get_complete_url_default_v1(self): + """With default_api_version='v1', URL should include /openai/v1/containers.""" + api_base = "https://my-resource.openai.azure.com" + litellm_params = {} + + url = self.config.get_complete_url( + api_base=api_base, litellm_params=litellm_params + ) + + assert "/openai/v1/containers" in url + assert "my-resource.openai.azure.com" in url + + def test_get_complete_url_with_explicit_api_version(self): + api_base = "https://my-resource.openai.azure.com" + litellm_params = {"api_version": "2025-01-01"} + + url = self.config.get_complete_url( + api_base=api_base, litellm_params=litellm_params + ) + + assert "api-version=2025-01-01" in url + assert "/openai/containers" in url + + def test_get_complete_url_with_latest_api_version(self): + api_base = "https://my-resource.openai.azure.com" + litellm_params = {"api_version": "latest"} + + url = self.config.get_complete_url( + api_base=api_base, litellm_params=litellm_params + ) + + assert "/openai/v1/containers" in url + + def test_get_complete_url_raises_without_api_base(self, monkeypatch): + monkeypatch.delenv("AZURE_API_BASE", raising=False) + monkeypatch.setattr(litellm, "api_base", None) + with pytest.raises(ValueError, match="api_base is required"): + self.config.get_complete_url(api_base=None, litellm_params={}) + + def test_transform_container_create_request(self): + from litellm.types.router import GenericLiteLLMParams + + litellm_params = GenericLiteLLMParams() + headers = {"api-key": "test-key"} + name = "My Azure Container" + optional_params = { + "expires_after": {"anchor": "last_active_at", "minutes": 30}, + "file_ids": ["file_abc"], + } + + data = self.config.transform_container_create_request( + name=name, + container_create_optional_request_params=optional_params, + litellm_params=litellm_params, + headers=headers, + ) + + assert data["name"] == name + assert data["expires_after"]["minutes"] == 30 + assert data["file_ids"] == ["file_abc"] + + def test_transform_container_create_response(self): + mock_response = MagicMock(spec=httpx.Response) + mock_response.json.return_value = { + "id": "cntr_azure_123", + "object": "container", + "created_at": 1747857508, + "status": "running", + "expires_after": {"anchor": "last_active_at", "minutes": 30}, + "last_active_at": 1747857508, + "name": "My Azure Container", + } + + container = self.config.transform_container_create_response( + raw_response=mock_response, logging_obj=self.logging_obj + ) + + assert isinstance(container, ContainerObject) + assert container.id == "cntr_azure_123" + assert container.name == "My Azure Container" + assert container.status == "running" + + def test_transform_container_list_request(self): + from litellm.types.router import GenericLiteLLMParams + + api_base = "https://my-resource.openai.azure.com/openai/v1/containers" + litellm_params = GenericLiteLLMParams() + headers = {"api-key": "test-key"} + + url, params = self.config.transform_container_list_request( + api_base=api_base, + litellm_params=litellm_params, + headers=headers, + limit=5, + order="desc", + ) + + assert url == api_base + assert params["limit"] == "5" + assert params["order"] == "desc" + + def test_transform_container_list_response(self): + mock_response = MagicMock(spec=httpx.Response) + mock_response.json.return_value = { + "object": "list", + "data": [ + { + "id": "cntr_1", + "object": "container", + "created_at": 1747857508, + "status": "running", + "expires_after": {"anchor": "last_active_at", "minutes": 20}, + "last_active_at": 1747857508, + "name": "Container 1", + } + ], + "first_id": "cntr_1", + "last_id": "cntr_1", + "has_more": False, + } + + container_list = self.config.transform_container_list_response( + raw_response=mock_response, logging_obj=self.logging_obj + ) + + assert isinstance(container_list, ContainerListResponse) + assert len(container_list.data) == 1 + assert container_list.first_id == "cntr_1" + + def test_transform_container_retrieve_request(self): + from litellm.types.router import GenericLiteLLMParams + + container_id = "cntr_azure_abc" + api_base = "https://my-resource.openai.azure.com/openai/v1/containers" + litellm_params = GenericLiteLLMParams() + headers = {"api-key": "test-key"} + + url, params = self.config.transform_container_retrieve_request( + container_id=container_id, + api_base=api_base, + litellm_params=litellm_params, + headers=headers, + ) + + assert url == f"{api_base}/{container_id}" + assert params == {} + + def test_transform_container_delete_request(self): + from litellm.types.router import GenericLiteLLMParams + + container_id = "cntr_azure_del" + api_base = "https://my-resource.openai.azure.com/openai/v1/containers" + litellm_params = GenericLiteLLMParams() + headers = {"api-key": "test-key"} + + url, params = self.config.transform_container_delete_request( + container_id=container_id, + api_base=api_base, + litellm_params=litellm_params, + headers=headers, + ) + + assert url == f"{api_base}/{container_id}" + assert params == {} + + def test_transform_container_delete_response(self): + mock_response = MagicMock(spec=httpx.Response) + mock_response.json.return_value = { + "id": "cntr_azure_del", + "object": "container.deleted", + "deleted": True, + } + + delete_result = self.config.transform_container_delete_response( + raw_response=mock_response, logging_obj=self.logging_obj + ) + + assert isinstance(delete_result, DeleteContainerResult) + assert delete_result.id == "cntr_azure_del" + assert delete_result.deleted is True + + def test_transform_container_file_list_request(self): + from litellm.types.router import GenericLiteLLMParams + + container_id = "cntr_azure_files" + api_base = "https://my-resource.openai.azure.com/openai/v1/containers" + litellm_params = GenericLiteLLMParams() + headers = {"api-key": "test-key"} + + url, params = self.config.transform_container_file_list_request( + container_id=container_id, + api_base=api_base, + litellm_params=litellm_params, + headers=headers, + limit=10, + ) + + assert url == f"{api_base}/{container_id}/files" + assert params["limit"] == "10" + + def test_transform_requests_preserve_query_string_after_path(self): + """api-version must not appear before /{container_id}/... (Azure bases include ?).""" + from litellm.types.router import GenericLiteLLMParams + + api_base = ( + "https://my-resource.openai.azure.com/openai/v1/containers" + "?api-version=v1" + ) + litellm_params = GenericLiteLLMParams() + headers: dict = {} + + url_r, _ = self.config.transform_container_retrieve_request( + container_id="cntr_x", + api_base=api_base, + litellm_params=litellm_params, + headers=headers, + ) + assert ( + url_r + == "https://my-resource.openai.azure.com/openai/v1/containers/cntr_x?api-version=v1" + ) + + url_fl, _ = self.config.transform_container_file_list_request( + container_id="cntr_x", + api_base=api_base, + litellm_params=litellm_params, + headers=headers, + ) + assert ( + url_fl + == "https://my-resource.openai.azure.com/openai/v1/containers/cntr_x/files?api-version=v1" + ) + + url_fc, _ = self.config.transform_container_file_content_request( + container_id="cntr_x", + file_id="cfile_y", + api_base=api_base, + litellm_params=litellm_params, + headers=headers, + ) + expected_fc = ( + "https://my-resource.openai.azure.com/openai/v1/containers/" + "cntr_x/files/cfile_y/content?api-version=v1" + ) + assert url_fc == expected_fc + assert url_fc.index("/content") < url_fc.index("?") + + def test_provider_config_manager_returns_azure_config(self): + from litellm.types.utils import LlmProviders + from litellm.utils import ProviderConfigManager + + config = ProviderConfigManager.get_provider_container_config( + provider=LlmProviders.AZURE + ) + + assert config is not None + assert isinstance(config, AzureContainerConfig) + + def test_proxy_handler_factory_returns_azure_config(self): + from litellm.proxy.container_endpoints.handler_factory import ( + _get_container_provider_config, + ) + + config = _get_container_provider_config("azure") + + assert config is not None + assert isinstance(config, AzureContainerConfig) + + def test_proxy_handler_factory_raises_for_unsupported_provider(self): + from litellm.proxy.container_endpoints.handler_factory import ( + _get_container_provider_config, + ) + + with pytest.raises(ValueError, match="Container API not supported"): + _get_container_provider_config("anthropic") + + +class TestAzureContainerKnownFailureRegressions: + """Regression tests for real production / proxy failures (Azure containers). + + 1. **URL / api-version** — ``get_complete_url`` appends ``?api-version=…`` to the + container base. Naïve ``f\"{api_base}/…\"`` put the query *before* path segments, + e.g. ``…/containers?api-version=v1/cntr_…/files``, which Azure rejects + ("API version not supported" / 404-style routing). + + 2. **Bare resource root** — ``AZURE_API_BASE`` is only the host (no ``?``). The + query appears only after LiteLLM builds the full container base; downstream + transforms must still append ``/cntr_…/files/…`` *before* the query string. + + 3. **File content path** — The worst case in logs was POST/GET logging showing + ``…containers?api-version=v1/cntr_…/files/cfile_…/content``; correct wire shape is + ``…containers/cntr_…/files/cfile_…/content?api-version=v1``. + """ + + def setup_method(self): + self.config = AzureContainerConfig() + + def test_regression_query_never_splits_before_container_segment(self): + """Forbid the broken shape: …/containers?api-version=v1/cntr_…""" + from litellm.types.router import GenericLiteLLMParams + + api_base = ( + "https://my-resource.openai.azure.com/openai/v1/containers" + "?api-version=v1" + ) + cid = "cntr_69d4f27de324819082c54f6aeaab6391056f5dbdf1fe2b02" + fid = "cfile_69d4f283bac0819094bfe7805a4f3ce8" + litellm_params = GenericLiteLLMParams() + headers: dict = {} + + url_fc, _ = self.config.transform_container_file_content_request( + container_id=cid, + file_id=fid, + api_base=api_base, + litellm_params=litellm_params, + headers=headers, + ) + # Exact substring seen in broken logs + assert "containers?api-version=v1/" + cid not in url_fc + assert "containers?api-version=v1/cntr_" not in url_fc + + parsed = urlparse(url_fc) + assert parsed.path == ( + f"/openai/v1/containers/{cid}/files/{fid}/content" + ) + assert parse_qs(parsed.query).get("api-version") == ["v1"] + assert url_fc.index("/content") < url_fc.index("?") + + def test_regression_full_chain_bare_resource_root_like_env(self): + """Mimics AZURE_API_BASE=https://resource.openai.azure.com — no ? in env.""" + from litellm.types.router import GenericLiteLLMParams + + resource_root = "https://my-resource.openai.azure.com" + container_base = self.config.get_complete_url( + api_base=resource_root, + litellm_params={}, + ) + assert "openai.azure.com" in container_base + assert "openai/v1/containers" in container_base or "/openai/containers" in container_base + + cid = "cntr_livepath123" + fid = "cfile_live456" + url_fc, params = self.config.transform_container_file_content_request( + container_id=cid, + file_id=fid, + api_base=container_base, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + assert cid in url_fc + assert fid in url_fc + parsed = urlparse(url_fc) + assert cid in parsed.path + assert "?" not in parsed.path + assert "/content" in parsed.path + assert url_fc.index(cid) < (url_fc.index("?") if "?" in url_fc else len(url_fc)) + assert params == {} + + def test_regression_all_crud_urls_with_azure_style_api_base(self): + """Retrieve, delete, list files, and file content all keep ?api-version last.""" + from litellm.types.router import GenericLiteLLMParams + + api_base = ( + "https://iamkankute-5584-resource.openai.azure.com/openai/v1/containers" + "?api-version=v1" + ) + cid = "cntr_69d4f1c5c6448190930a444af3f84f670b35dc2ee845cd1b" + fid = "cfile_69d4f1c97a1081908d22a9f56268c743" + litellm_params = GenericLiteLLMParams() + headers: dict = {} + + url_r, _ = self.config.transform_container_retrieve_request( + container_id=cid, + api_base=api_base, + litellm_params=litellm_params, + headers=headers, + ) + url_d, _ = self.config.transform_container_delete_request( + container_id=cid, + api_base=api_base, + litellm_params=litellm_params, + headers=headers, + ) + url_lf, _ = self.config.transform_container_file_list_request( + container_id=cid, + api_base=api_base, + litellm_params=litellm_params, + headers=headers, + ) + url_fc, _ = self.config.transform_container_file_content_request( + container_id=cid, + file_id=fid, + api_base=api_base, + litellm_params=litellm_params, + headers=headers, + ) + + for name, u in ( + ("retrieve", url_r), + ("delete", url_d), + ("list_files", url_lf), + ("file_content", url_fc), + ): + assert f"containers?api-version=v1/{cid}" not in u, name + p = urlparse(u) + assert cid in p.path, name + assert "api-version" in p.query or "api-version=v1" in u, name + + assert urlparse(url_fc).path.endswith(f"/{cid}/files/{fid}/content") + + def test_regression_api_base_with_extra_query_params(self): + """Multiple query params must stay at the end after path join.""" + from litellm.types.router import GenericLiteLLMParams + + api_base = ( + "https://my-resource.openai.azure.com/openai/v1/containers" + "?api-version=v1&foo=bar" + ) + cid = "cntr_x" + url_lf, _ = self.config.transform_container_file_list_request( + container_id=cid, + api_base=api_base, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + p = urlparse(url_lf) + assert p.path == f"/openai/v1/containers/{cid}/files" + qs = parse_qs(p.query) + assert qs.get("api-version") == ["v1"] + assert qs.get("foo") == ["bar"] + + def test_regression_proxy_resolves_azure_text_same_as_azure(self): + """Router/proxy treat azure_text like azure for container config.""" + from litellm.proxy.container_endpoints.handler_factory import ( + _get_container_provider_config, + ) + + c1 = _get_container_provider_config("azure") + c2 = _get_container_provider_config("azure_text") + assert type(c1) is type(c2) + assert isinstance(c1, AzureContainerConfig) diff --git a/tests/test_litellm/containers/test_container_utils.py b/tests/test_litellm/containers/test_container_utils.py index 356e1ccda6f..baeb568d94d 100644 --- a/tests/test_litellm/containers/test_container_utils.py +++ b/tests/test_litellm/containers/test_container_utils.py @@ -12,7 +12,8 @@ from litellm.containers.utils import ContainerRequestUtils from litellm.llms.openai.containers.transformation import OpenAIContainerConfig from litellm.types.containers.main import ( ContainerCreateOptionalRequestParams, - ContainerListOptionalRequestParams + ContainerListOptionalRequestParams, + DeleteContainerFileResponse, ) @@ -228,3 +229,23 @@ class TestContainerRequestUtils: ) assert result["expires_after"]["minutes"] == 15 + + +class TestDeleteContainerFileResponseWireFormat: + """OpenAI / Azure return ``container.file.deleted`` on DELETE file.""" + + def test_accepts_openai_dot_notation(self): + m = DeleteContainerFileResponse( + id="cfile_abc", + object="container.file.deleted", + deleted=True, + ) + assert m.object == "container.file.deleted" + + def test_accepts_legacy_underscore(self): + m = DeleteContainerFileResponse( + id="cfile_abc", + object="container_file.deleted", + deleted=True, + ) + assert m.object == "container_file.deleted"