diff --git a/litellm/llms/anthropic/batches/transformation.py b/litellm/llms/anthropic/batches/transformation.py index 3f03c744efe..d68b15aba4a 100644 --- a/litellm/llms/anthropic/batches/transformation.py +++ b/litellm/llms/anthropic/batches/transformation.py @@ -5,6 +5,7 @@ from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Union, cas import httpx from httpx import Headers, Response +from litellm.llms.base_llm._url_utils import encode_path_segment from litellm.llms.base_llm.batches.transformation import BaseBatchesConfig from litellm.llms.base_llm.chat.transformation import BaseLLMException from litellm.types.llms.openai import AllMessageValues, CreateBatchRequest @@ -122,7 +123,7 @@ class AnthropicBatchesConfig(BaseBatchesConfig): Complete URL for Anthropic batch retrieval: {api_base}/v1/messages/batches/{batch_id} """ api_base = api_base or self.anthropic_model_info.get_api_base(api_base) - return f"{api_base.rstrip('/')}/v1/messages/batches/{batch_id}" + return f"{api_base.rstrip('/')}/v1/messages/batches/{encode_path_segment(batch_id)}" def transform_retrieve_batch_request( self, diff --git a/litellm/llms/anthropic/files/handler.py b/litellm/llms/anthropic/files/handler.py index c56799f30cf..2ed402a8827 100644 --- a/litellm/llms/anthropic/files/handler.py +++ b/litellm/llms/anthropic/files/handler.py @@ -9,6 +9,7 @@ import litellm from litellm._logging import verbose_logger from litellm._uuid import uuid from litellm.litellm_core_utils.litellm_logging import Logging +from litellm.llms.base_llm._url_utils import encode_path_segment from litellm.llms.custom_httpx.http_handler import get_async_httpx_client from litellm.types.llms.openai import ( FileContentRequest, @@ -89,7 +90,7 @@ class AnthropicFilesHandler: raise ValueError("Missing Anthropic API Key") # Construct the Anthropic batch results URL - results_url = f"{api_base.rstrip('/')}/v1/messages/batches/{batch_id}/results" + results_url = f"{api_base.rstrip('/')}/v1/messages/batches/{encode_path_segment(batch_id)}/results" # Prepare headers headers = { diff --git a/litellm/llms/anthropic/files/transformation.py b/litellm/llms/anthropic/files/transformation.py index aeaab4e57bf..7ad65eca932 100644 --- a/litellm/llms/anthropic/files/transformation.py +++ b/litellm/llms/anthropic/files/transformation.py @@ -20,6 +20,7 @@ import httpx from openai.types.file_deleted import FileDeleted from litellm.litellm_core_utils.prompt_templates.common_utils import extract_file_data +from litellm.llms.base_llm._url_utils import encode_path_segment from litellm.llms.base_llm.chat.transformation import BaseLLMException from litellm.llms.base_llm.files.transformation import ( BaseFilesConfig, @@ -185,7 +186,7 @@ class AnthropicFilesConfig(BaseFilesConfig): AnthropicModelInfo.get_api_base(litellm_params.get("api_base")) or ANTHROPIC_FILES_API_BASE ) - return f"{api_base.rstrip('/')}/v1/files/{file_id}", {} + return f"{api_base.rstrip('/')}/v1/files/{encode_path_segment(file_id)}", {} def transform_retrieve_file_response( self, @@ -206,7 +207,7 @@ class AnthropicFilesConfig(BaseFilesConfig): AnthropicModelInfo.get_api_base(litellm_params.get("api_base")) or ANTHROPIC_FILES_API_BASE ) - return f"{api_base.rstrip('/')}/v1/files/{file_id}", {} + return f"{api_base.rstrip('/')}/v1/files/{encode_path_segment(file_id)}", {} def transform_delete_file_response( self, @@ -268,7 +269,10 @@ class AnthropicFilesConfig(BaseFilesConfig): AnthropicModelInfo.get_api_base(litellm_params.get("api_base")) or ANTHROPIC_FILES_API_BASE ) - return f"{api_base.rstrip('/')}/v1/files/{file_id}/content", {} + return ( + f"{api_base.rstrip('/')}/v1/files/{encode_path_segment(file_id)}/content", + {}, + ) def transform_file_content_response( self, diff --git a/litellm/llms/anthropic/skills/transformation.py b/litellm/llms/anthropic/skills/transformation.py index a992d84d459..223b70a2aaf 100644 --- a/litellm/llms/anthropic/skills/transformation.py +++ b/litellm/llms/anthropic/skills/transformation.py @@ -7,6 +7,7 @@ from typing import Any, Dict, Optional, Tuple import httpx from litellm._logging import verbose_logger +from litellm.llms.base_llm._url_utils import encode_path_segment from litellm.llms.base_llm.skills.transformation import ( BaseSkillsAPIConfig, LiteLLMLoggingObj, @@ -81,7 +82,7 @@ class AnthropicSkillsConfig(BaseSkillsAPIConfig): api_base = AnthropicModelInfo.get_api_base() if skill_id: - return f"{api_base}/v1/skills/{skill_id}" + return f"{api_base}/v1/skills/{encode_path_segment(skill_id)}" return f"{api_base}/v1/{endpoint}" def transform_create_skill_request( diff --git a/litellm/llms/azure/common_utils.py b/litellm/llms/azure/common_utils.py index 4fc1ae960b8..c11ae0b877e 100644 --- a/litellm/llms/azure/common_utils.py +++ b/litellm/llms/azure/common_utils.py @@ -8,6 +8,7 @@ from openai import AsyncAzureOpenAI, AsyncOpenAI, AzureOpenAI, OpenAI import litellm from litellm._logging import verbose_logger from litellm.caching.caching import DualCache +from litellm.llms.base_llm._url_utils import encode_path_segment from litellm.llms.base_llm.chat.transformation import BaseLLMException from litellm.llms.openai.common_utils import BaseOpenAILLM from litellm.secret_managers.get_azure_ad_token_provider import ( @@ -211,7 +212,7 @@ def get_azure_ad_token_from_oidc( client = litellm.module_level_client req_token = client.post( - f"{azure_authority_host}/{azure_tenant_id}/oauth2/v2.0/token", + f"{azure_authority_host}/{encode_path_segment(azure_tenant_id)}/oauth2/v2.0/token", data={ "client_id": azure_client_id, "grant_type": "client_credentials", diff --git a/litellm/llms/azure/responses/transformation.py b/litellm/llms/azure/responses/transformation.py index 76a6d485bc4..8649c619258 100644 --- a/litellm/llms/azure/responses/transformation.py +++ b/litellm/llms/azure/responses/transformation.py @@ -6,6 +6,7 @@ from openai.types.responses import ResponseReasoningItem from litellm._logging import verbose_logger from litellm.llms.azure.common_utils import BaseAzureLLM +from litellm.llms.base_llm._url_utils import encode_path_segment from litellm.llms.openai.responses.transformation import OpenAIResponsesAPIConfig from litellm.types.llms.openai import * from litellm.types.responses.main import * @@ -201,7 +202,7 @@ class AzureOpenAIResponsesAPIConfig(OpenAIResponsesAPIConfig): # Insert the response_id at the end of the path component # Remove trailing slash if present to avoid double slashes path = parsed_url.path.rstrip("/") - new_path = f"{path}/{response_id}" + new_path = f"{path}/{encode_path_segment(response_id)}" # Reconstruct the URL with all original components but with the modified path constructed_url = urlunparse( @@ -322,7 +323,7 @@ class AzureOpenAIResponsesAPIConfig(OpenAIResponsesAPIConfig): # Insert the response_id and /cancel at the end of the path component # Remove trailing slash if present to avoid double slashes path = parsed_url.path.rstrip("/") - new_path = f"{path}/{response_id}/cancel" + new_path = f"{path}/{encode_path_segment(response_id)}/cancel" # Reconstruct the URL with all original components but with the modified path cancel_url = urlunparse( diff --git a/litellm/llms/azure_ai/agents/handler.py b/litellm/llms/azure_ai/agents/handler.py index c3cd06ab4de..17d95c482cb 100644 --- a/litellm/llms/azure_ai/agents/handler.py +++ b/litellm/llms/azure_ai/agents/handler.py @@ -40,6 +40,7 @@ from litellm.llms.azure_ai.agents.transformation import ( AzureAIAgentsConfig, AzureAIAgentsError, ) +from litellm.llms.base_llm._url_utils import encode_path_segment from litellm.types.utils import ModelResponse if TYPE_CHECKING: @@ -75,20 +76,20 @@ class AzureAIAgentsHandler: def _build_messages_url( self, api_base: str, thread_id: str, api_version: str ) -> str: - return f"{api_base}/threads/{thread_id}/messages?api-version={api_version}" + return f"{api_base}/threads/{encode_path_segment(thread_id)}/messages?api-version={api_version}" def _build_runs_url(self, api_base: str, thread_id: str, api_version: str) -> str: - return f"{api_base}/threads/{thread_id}/runs?api-version={api_version}" + return f"{api_base}/threads/{encode_path_segment(thread_id)}/runs?api-version={api_version}" def _build_run_status_url( self, api_base: str, thread_id: str, run_id: str, api_version: str ) -> str: - return f"{api_base}/threads/{thread_id}/runs/{run_id}?api-version={api_version}" + return f"{api_base}/threads/{encode_path_segment(thread_id)}/runs/{encode_path_segment(run_id)}?api-version={api_version}" def _build_list_messages_url( self, api_base: str, thread_id: str, api_version: str ) -> str: - return f"{api_base}/threads/{thread_id}/messages?api-version={api_version}" + return f"{api_base}/threads/{encode_path_segment(thread_id)}/messages?api-version={api_version}" def _build_create_thread_and_run_url(self, api_base: str, api_version: str) -> str: """URL for the create-thread-and-run endpoint (supports streaming).""" diff --git a/litellm/llms/azure_ai/ocr/document_intelligence/transformation.py b/litellm/llms/azure_ai/ocr/document_intelligence/transformation.py index 76c247aea81..b993ae81418 100644 --- a/litellm/llms/azure_ai/ocr/document_intelligence/transformation.py +++ b/litellm/llms/azure_ai/ocr/document_intelligence/transformation.py @@ -22,6 +22,7 @@ from litellm.constants import ( AZURE_DOCUMENT_INTELLIGENCE_DEFAULT_DPI, AZURE_OPERATION_POLLING_TIMEOUT, ) +from litellm.llms.base_llm._url_utils import encode_path_segment from litellm.llms.base_llm.ocr.transformation import ( BaseOCRConfig, DocumentType, @@ -220,10 +221,7 @@ class AzureDocumentIntelligenceOCRConfig(BaseOCRConfig): # Azure Document Intelligence analyze endpoint # Note: API version 2024-11-30+ uses /documentintelligence/ (not /formrecognizer/) - url = ( - f"{api_base}/documentintelligence/documentModels/{model_id}:analyze" - f"?api-version={AZURE_DOCUMENT_INTELLIGENCE_API_VERSION}" - ) + url = f"{api_base}/documentintelligence/documentModels/{encode_path_segment(model_id)}:analyze?api-version={AZURE_DOCUMENT_INTELLIGENCE_API_VERSION}" # Azure DI accepts `pages` as a query param (1-based, e.g. "1-3,5"). # `optional_params` has already been normalized in `map_ocr_params`. diff --git a/litellm/llms/azure_ai/vector_stores/transformation.py b/litellm/llms/azure_ai/vector_stores/transformation.py index b62acb65166..c52465a27e8 100644 --- a/litellm/llms/azure_ai/vector_stores/transformation.py +++ b/litellm/llms/azure_ai/vector_stores/transformation.py @@ -4,6 +4,7 @@ import httpx import litellm from litellm.llms.azure.common_utils import BaseAzureLLM +from litellm.llms.base_llm._url_utils import encode_path_segment from litellm.llms.base_llm.vector_store.transformation import BaseVectorStoreConfig from litellm.types.router import GenericLiteLLMParams from litellm.types.vector_stores import ( @@ -139,7 +140,7 @@ class AzureAIVectorStoreConfig(BaseVectorStoreConfig, BaseAzureLLM): # Azure AI Search endpoint for search index_name = vector_store_id # vector_store_id is the index name - url = f"{api_base}/indexes/{index_name}/docs/search?api-version=2024-07-01" + url = f"{api_base}/indexes/{encode_path_segment(index_name)}/docs/search?api-version=2024-07-01" # Build the request body for Azure AI Search with vector search request_body = { diff --git a/litellm/llms/base_llm/_url_utils.py b/litellm/llms/base_llm/_url_utils.py new file mode 100644 index 00000000000..a5791b9de3b --- /dev/null +++ b/litellm/llms/base_llm/_url_utils.py @@ -0,0 +1,37 @@ +"""URL-encoding helpers for provider transformations.""" + +from typing import Optional +from urllib.parse import quote + + +def _reject_dot_segment(segment: str, full: str) -> None: + if segment in ("..", "."): + raise ValueError(f"Illegal path segment in identifier: {full!r}") + + +def encode_path_segment(segment: Optional[str], safe: str = "") -> str: + """Percent-encode a single path segment. Raises on empty / ``None`` / ``.`` / ``..``. + + ``safe`` is forwarded to ``urllib.parse.quote`` for callers that need to + preserve specific characters (e.g. ``:`` in Bedrock model IDs). + """ + if segment is None or segment == "": + raise ValueError("identifier is required, got empty or None") + str_segment = str(segment) + _reject_dot_segment(str_segment, str_segment) + return quote(str_segment, safe=safe) + + +def encode_url_path(path: Optional[str]) -> str: + """Percent-encode a multi-segment path; preserves ``/`` and ``@``. + + ``None`` / ``""`` → ``""``. Rejects ``.``, ``..``, or empty segments. + """ + if path is None or path == "": + return "" + str_path = str(path) + for segment in str_path.split("/"): + if segment == "": + raise ValueError(f"Empty path segment in identifier: {str_path!r}") + _reject_dot_segment(segment, str_path) + return quote(str_path, safe="/@") diff --git a/litellm/llms/bedrock/chat/invoke_agent/transformation.py b/litellm/llms/bedrock/chat/invoke_agent/transformation.py index 2c7135f4d83..3a88ae1c4a5 100644 --- a/litellm/llms/bedrock/chat/invoke_agent/transformation.py +++ b/litellm/llms/bedrock/chat/invoke_agent/transformation.py @@ -15,6 +15,7 @@ from litellm._uuid import uuid from litellm.litellm_core_utils.prompt_templates.common_utils import ( convert_content_list_to_str, ) +from litellm.llms.base_llm._url_utils import encode_path_segment from litellm.llms.base_llm.chat.transformation import BaseConfig, BaseLLMException from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM from litellm.llms.bedrock.common_utils import BedrockError @@ -98,7 +99,7 @@ class AmazonInvokeAgentConfig(BaseConfig, BaseAWSLLM): agent_id, agent_alias_id = self._get_agent_id_and_alias_id(model) session_id = self._get_session_id(optional_params) - endpoint_url = f"{endpoint_url}/agents/{agent_id}/agentAliases/{agent_alias_id}/sessions/{session_id}/text" + endpoint_url = f"{endpoint_url}/agents/{encode_path_segment(agent_id)}/agentAliases/{encode_path_segment(agent_alias_id)}/sessions/{encode_path_segment(session_id)}/text" return endpoint_url diff --git a/litellm/llms/bedrock/chat/invoke_transformations/amazon_openai_transformation.py b/litellm/llms/bedrock/chat/invoke_transformations/amazon_openai_transformation.py index 7b64c6066d0..a79e9c3017b 100644 --- a/litellm/llms/bedrock/chat/invoke_transformations/amazon_openai_transformation.py +++ b/litellm/llms/bedrock/chat/invoke_transformations/amazon_openai_transformation.py @@ -11,6 +11,7 @@ from typing import TYPE_CHECKING, Any, List, Optional, Tuple, Union import httpx +from litellm.llms.base_llm._url_utils import encode_path_segment from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM from litellm.llms.bedrock.common_utils import BedrockError from litellm.llms.openai.chat.gpt_transformation import OpenAIGPTConfig @@ -98,6 +99,9 @@ class AmazonBedrockOpenAIConfig(OpenAIGPTConfig, BaseAWSLLM): # Encode model ID for ARNs (e.g., :imported-model/ -> :imported-model%2F) model_id = CommonUtils.encode_bedrock_runtime_modelid_arn(model_id) + if "arn:aws:" not in model_id: + # Bedrock model IDs use ':' for version (e.g. amazon.nova-pro-v1:0) + model_id = encode_path_segment(model_id, safe=":") # Build the invoke URL if stream: diff --git a/litellm/llms/bedrock/count_tokens/transformation.py b/litellm/llms/bedrock/count_tokens/transformation.py index a37af131625..cf2d6aea91a 100644 --- a/litellm/llms/bedrock/count_tokens/transformation.py +++ b/litellm/llms/bedrock/count_tokens/transformation.py @@ -8,6 +8,7 @@ to AWS Bedrock's CountTokens API format and vice versa. import re from typing import Any, Dict, List, Optional +from litellm.llms.base_llm._url_utils import encode_path_segment from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM from litellm.llms.bedrock.common_utils import get_bedrock_base_model @@ -207,7 +208,10 @@ class BedrockCountTokensConfig(BaseAWSLLM): aws_bedrock_runtime_endpoint=aws_bedrock_runtime_endpoint, aws_region_name=aws_region_name, ) - endpoint = f"{base_url}/model/{model_id}/count-tokens" + # Bedrock model IDs use ':' for version (e.g. amazon.nova-pro-v1:0) + endpoint = ( + f"{base_url}/model/{encode_path_segment(model_id, safe=':')}/count-tokens" + ) return endpoint diff --git a/litellm/llms/bedrock/files/transformation.py b/litellm/llms/bedrock/files/transformation.py index 3007b54808c..6927be636e3 100644 --- a/litellm/llms/bedrock/files/transformation.py +++ b/litellm/llms/bedrock/files/transformation.py @@ -11,6 +11,7 @@ from litellm._logging import verbose_logger from litellm._uuid import uuid from litellm.files.utils import FilesAPIUtils from litellm.litellm_core_utils.prompt_templates.common_utils import extract_file_data +from litellm.llms.base_llm._url_utils import encode_path_segment, encode_url_path from litellm.llms.base_llm.chat.transformation import BaseLLMException from litellm.llms.base_llm.files.transformation import ( BaseFilesConfig, @@ -195,7 +196,7 @@ class BedrockFilesConfig(BaseAWSLLM, BaseFilesConfig): or f"https://s3.{aws_region_name}.amazonaws.com" ) - return f"{s3_endpoint_url}/{bucket_name}/{object_name}" + return f"{s3_endpoint_url}/{encode_path_segment(bucket_name)}/{encode_url_path(object_name)}" def get_supported_openai_params( self, model: str diff --git a/litellm/llms/bedrock/vector_stores/transformation.py b/litellm/llms/bedrock/vector_stores/transformation.py index 4da0a7c7791..a64d57f638b 100644 --- a/litellm/llms/bedrock/vector_stores/transformation.py +++ b/litellm/llms/bedrock/vector_stores/transformation.py @@ -3,6 +3,7 @@ from urllib.parse import urlparse import httpx +from litellm.llms.base_llm._url_utils import encode_path_segment from litellm.llms.base_llm.vector_store.transformation import BaseVectorStoreConfig from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM from litellm.types.integrations.rag.bedrock_knowledgebase import ( @@ -206,7 +207,7 @@ class BedrockVectorStoreConfig(BaseVectorStoreConfig, BaseAWSLLM): if isinstance(query, list): query = " ".join(query) - url = f"{api_base}/{vector_store_id}/retrieve" + url = f"{api_base}/{encode_path_segment(vector_store_id)}/retrieve" request_body: Dict[str, Any] = { "retrievalQuery": BedrockKBRetrievalQuery(text=query), diff --git a/litellm/llms/bytez/chat/transformation.py b/litellm/llms/bytez/chat/transformation.py index a72f732a303..5e1ec6fbf47 100644 --- a/litellm/llms/bytez/chat/transformation.py +++ b/litellm/llms/bytez/chat/transformation.py @@ -7,6 +7,7 @@ import httpx from litellm.litellm_core_utils.exception_mapping_utils import exception_type from litellm.litellm_core_utils.logging_utils import track_llm_api_timing +from litellm.llms.base_llm._url_utils import encode_url_path from litellm.llms.base_llm.chat.transformation import BaseConfig, BaseLLMException from litellm.llms.custom_httpx.http_handler import ( AsyncHTTPHandler, @@ -149,7 +150,7 @@ class BytezChatConfig(BaseConfig): litellm_params: dict, stream: Optional[bool] = None, ) -> str: - return f"{API_BASE}/{model}" + return f"{API_BASE}/{encode_url_path(model)}" def transform_request( self, diff --git a/litellm/llms/cloudflare/chat/transformation.py b/litellm/llms/cloudflare/chat/transformation.py index 9e59782bf73..c3a7683a8e7 100644 --- a/litellm/llms/cloudflare/chat/transformation.py +++ b/litellm/llms/cloudflare/chat/transformation.py @@ -5,6 +5,7 @@ from typing import AsyncIterator, Iterator, List, Optional, Union import httpx import litellm +from litellm.llms.base_llm._url_utils import encode_url_path from litellm.llms.base_llm.base_model_iterator import BaseModelResponseIterator from litellm.llms.base_llm.chat.transformation import ( BaseConfig, @@ -89,7 +90,7 @@ class CloudflareChatConfig(BaseConfig): api_base = ( f"https://api.cloudflare.com/client/v4/accounts/{account_id}/ai/run/" ) - return api_base + model + return api_base + encode_url_path(model) def get_supported_openai_params(self, model: str) -> List[str]: return [ diff --git a/litellm/llms/custom_httpx/container_handler.py b/litellm/llms/custom_httpx/container_handler.py index afdd7bc6a8b..82893714cf5 100644 --- a/litellm/llms/custom_httpx/container_handler.py +++ b/litellm/llms/custom_httpx/container_handler.py @@ -12,6 +12,7 @@ from typing import TYPE_CHECKING, Any, Coroutine, Dict, Optional, Type, Union import httpx import litellm +from litellm.llms.base_llm._url_utils import encode_path_segment from litellm.llms.custom_httpx.http_handler import ( AsyncHTTPHandler, HTTPHandler, @@ -72,7 +73,9 @@ def _build_url( # Substitute path parameters for param, value in path_params.items(): - path_template = path_template.replace(f"{{{param}}}", value) + path_template = path_template.replace( + f"{{{param}}}", encode_path_segment(value) + ) # Parse the api_base to extract existing query params parsed_base = httpx.URL(api_base) diff --git a/litellm/llms/custom_httpx/llm_http_handler.py b/litellm/llms/custom_httpx/llm_http_handler.py index b9ada079f6b..8d6e564aeb8 100644 --- a/litellm/llms/custom_httpx/llm_http_handler.py +++ b/litellm/llms/custom_httpx/llm_http_handler.py @@ -26,6 +26,7 @@ from litellm._logging import _redact_string, verbose_logger from litellm.anthropic_beta_headers_manager import update_headers_with_filtered_beta from litellm.constants import REALTIME_WEBSOCKET_MAX_MESSAGE_SIZE_BYTES from litellm.litellm_core_utils.realtime_streaming import RealTimeStreaming +from litellm.llms.base_llm._url_utils import encode_path_segment from litellm.llms.base_llm.anthropic_messages.transformation import ( BaseAnthropicMessagesConfig, ) @@ -8907,7 +8908,7 @@ class BaseLLMHTTPHandler: litellm_params=dict(litellm_params), ) - url = f"{api_base}/{vector_store_id}" + url = f"{api_base}/{encode_path_segment(vector_store_id)}" logging_obj.pre_call( input="", @@ -8974,7 +8975,7 @@ class BaseLLMHTTPHandler: litellm_params=dict(litellm_params), ) - url = f"{api_base}/{vector_store_id}" + url = f"{api_base}/{encode_path_segment(vector_store_id)}" logging_obj.pre_call( input="", @@ -9173,7 +9174,7 @@ class BaseLLMHTTPHandler: litellm_params=dict(litellm_params), ) - url = f"{api_base}/{vector_store_id}" + url = f"{api_base}/{encode_path_segment(vector_store_id)}" request_body: Dict[str, Any] = dict(vector_store_update_optional_params) @@ -9256,7 +9257,7 @@ class BaseLLMHTTPHandler: litellm_params=dict(litellm_params), ) - url = f"{api_base}/{vector_store_id}" + url = f"{api_base}/{encode_path_segment(vector_store_id)}" request_body: Dict[str, Any] = dict(vector_store_update_optional_params) @@ -9322,7 +9323,7 @@ class BaseLLMHTTPHandler: litellm_params=dict(litellm_params), ) - url = f"{api_base}/{vector_store_id}" + url = f"{api_base}/{encode_path_segment(vector_store_id)}" logging_obj.pre_call( input="", @@ -9387,7 +9388,7 @@ class BaseLLMHTTPHandler: litellm_params=dict(litellm_params), ) - url = f"{api_base}/{vector_store_id}" + url = f"{api_base}/{encode_path_segment(vector_store_id)}" logging_obj.pre_call( input="", diff --git a/litellm/llms/elevenlabs/text_to_speech/transformation.py b/litellm/llms/elevenlabs/text_to_speech/transformation.py index 4dac2b8ba92..2ee018ccf57 100644 --- a/litellm/llms/elevenlabs/text_to_speech/transformation.py +++ b/litellm/llms/elevenlabs/text_to_speech/transformation.py @@ -12,6 +12,7 @@ from httpx import Headers import litellm from litellm.types.utils import all_litellm_params +from litellm.llms.base_llm._url_utils import encode_path_segment from litellm.llms.base_llm.chat.transformation import BaseLLMException from litellm.llms.base_llm.text_to_speech.transformation import ( BaseTextToSpeechConfig, @@ -321,7 +322,7 @@ class ElevenLabsTextToSpeechConfig(BaseTextToSpeechConfig): "ElevenLabs voice_id is required. Pass `voice` when calling `litellm.speech()`." ) - url = f"{base_url}{self.TTS_ENDPOINT_PATH}/{voice_id}" + url = f"{base_url}{self.TTS_ENDPOINT_PATH}/{encode_path_segment(voice_id)}" query_params = litellm_params.get(self.ELEVENLABS_QUERY_PARAMS_KEY, {}) if query_params: diff --git a/litellm/llms/fireworks_ai/chat/transformation.py b/litellm/llms/fireworks_ai/chat/transformation.py index ed6d167a118..340bbb58b27 100644 --- a/litellm/llms/fireworks_ai/chat/transformation.py +++ b/litellm/llms/fireworks_ai/chat/transformation.py @@ -10,6 +10,7 @@ from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLogging from litellm.litellm_core_utils.llm_response_utils.get_headers import ( get_response_headers, ) +from litellm.llms.base_llm._url_utils import encode_path_segment from litellm.secret_managers.main import get_secret_str from litellm.types.llms.openai import ( AllMessageValues, @@ -438,7 +439,7 @@ class FireworksAIConfig(OpenAIGPTConfig): if base.endswith("/v1"): base = base[: -len("/v1")] response = litellm.module_level_client.get( - url=f"{base}/v1/accounts/{account_id}/models", + url=f"{base}/v1/accounts/{encode_path_segment(account_id)}/models", headers={"Authorization": f"Bearer {api_key}"}, ) diff --git a/litellm/llms/gemini/count_tokens/handler.py b/litellm/llms/gemini/count_tokens/handler.py index fdb77452d4c..16c3dcddb49 100644 --- a/litellm/llms/gemini/count_tokens/handler.py +++ b/litellm/llms/gemini/count_tokens/handler.py @@ -3,6 +3,7 @@ from typing import TYPE_CHECKING, Any, Dict, Optional, Tuple, Union import httpx import litellm +from litellm.llms.base_llm._url_utils import encode_url_path from litellm.llms.custom_httpx.http_handler import get_async_httpx_client from litellm.types.utils import LlmProviders @@ -54,7 +55,7 @@ class GoogleAIStudioTokenCounter: Construct the URL for the Google Gen AI Studio countTokens endpoint. """ base_url = api_base or "https://generativelanguage.googleapis.com" - return f"{base_url}/v1beta/models/{model}:countTokens" + return f"{base_url}/v1beta/models/{encode_url_path(model)}:countTokens" async def validate_environment( self, diff --git a/litellm/llms/gemini/files/transformation.py b/litellm/llms/gemini/files/transformation.py index 401d7bb9f48..1611ada2e6f 100644 --- a/litellm/llms/gemini/files/transformation.py +++ b/litellm/llms/gemini/files/transformation.py @@ -13,6 +13,7 @@ from openai.types.file_deleted import FileDeleted from litellm._logging import verbose_logger from litellm.litellm_core_utils.prompt_templates.common_utils import extract_file_data +from litellm.llms.base_llm._url_utils import encode_url_path from litellm.llms.base_llm.files.transformation import ( BaseFilesConfig, LiteLLMLoggingObj, @@ -232,7 +233,7 @@ class GoogleAIStudioFilesHandler(GeminiModelInfo, BaseFilesConfig): ) api_base = api_base.rstrip("/") - url = f"{api_base}/v1beta/{file_part}" + url = f"{api_base}/v1beta/{encode_url_path(file_part)}" # API key is passed via x-goog-api-key header (set in validate_environment) return url, {} @@ -346,7 +347,7 @@ class GoogleAIStudioFilesHandler(GeminiModelInfo, BaseFilesConfig): file_name = file_id if file_id.startswith("files/") else f"files/{file_id}" # Construct the delete URL - url = f"{api_base}/v1beta/{file_name}" + url = f"{api_base}/v1beta/{encode_url_path(file_name)}" # Add API key as header (Google AI Studio uses x-goog-api-key header) params: dict = {} diff --git a/litellm/llms/gemini/image_edit/transformation.py b/litellm/llms/gemini/image_edit/transformation.py index c8aaab0e14e..64d56377ea2 100644 --- a/litellm/llms/gemini/image_edit/transformation.py +++ b/litellm/llms/gemini/image_edit/transformation.py @@ -6,6 +6,7 @@ import httpx from httpx._types import RequestFiles from litellm.images.utils import ImageEditRequestUtils +from litellm.llms.base_llm._url_utils import encode_url_path from litellm.llms.base_llm.image_edit.transformation import BaseImageEditConfig from litellm.secret_managers.main import get_secret_str from litellm.types.images.main import ImageEditOptionalRequestParams @@ -79,7 +80,7 @@ class GeminiImageEditConfig(BaseImageEditConfig): api_base or get_secret_str("GEMINI_API_BASE") or self.DEFAULT_BASE_URL ) base_url = base_url.rstrip("/") - return f"{base_url}/models/{model}:generateContent" + return f"{base_url}/models/{encode_url_path(model)}:generateContent" def transform_image_edit_request( # type: ignore[override] self, diff --git a/litellm/llms/gemini/image_generation/transformation.py b/litellm/llms/gemini/image_generation/transformation.py index 9c4cd008b8c..afe5297fa90 100644 --- a/litellm/llms/gemini/image_generation/transformation.py +++ b/litellm/llms/gemini/image_generation/transformation.py @@ -2,6 +2,7 @@ from typing import TYPE_CHECKING, Any, List, Optional import httpx +from litellm.llms.base_llm._url_utils import encode_url_path from litellm.llms.base_llm.image_generation.transformation import ( BaseImageGenerationConfig, ) @@ -127,11 +128,12 @@ class GoogleImageGenConfig(BaseImageGenerationConfig): complete_url = complete_url.rstrip("/") # Gemini Flash Image Preview models use generateContent endpoint + encoded_model = encode_url_path(model) if "gemini" in model: - complete_url = f"{complete_url}/models/{model}:generateContent" + complete_url = f"{complete_url}/models/{encoded_model}:generateContent" else: # All other Imagen models use predict endpoint - complete_url = f"{complete_url}/models/{model}:predict" + complete_url = f"{complete_url}/models/{encoded_model}:predict" return complete_url diff --git a/litellm/llms/gemini/interactions/transformation.py b/litellm/llms/gemini/interactions/transformation.py index c34da83cb8f..75c7fc7d801 100644 --- a/litellm/llms/gemini/interactions/transformation.py +++ b/litellm/llms/gemini/interactions/transformation.py @@ -15,6 +15,7 @@ import httpx from litellm._logging import verbose_logger from litellm.litellm_core_utils.core_helpers import process_response_headers +from litellm.llms.base_llm._url_utils import encode_path_segment from litellm.llms.base_llm.interactions.transformation import BaseInteractionsAPIConfig from litellm.llms.gemini.common_utils import GeminiError, GeminiModelInfo from litellm.types.interactions import ( @@ -206,7 +207,7 @@ class GoogleAIStudioInteractionsConfig(BaseInteractionsAPIConfig): if not GeminiModelInfo.get_api_key(litellm_params.api_key): raise ValueError("Google API key is required") return ( - f"{resolved_api_base}/{self.api_version}/interactions/{interaction_id}", + f"{resolved_api_base}/{self.api_version}/interactions/{encode_path_segment(interaction_id)}", {}, ) @@ -239,7 +240,7 @@ class GoogleAIStudioInteractionsConfig(BaseInteractionsAPIConfig): if not GeminiModelInfo.get_api_key(litellm_params.api_key): raise ValueError("Google API key is required") return ( - f"{resolved_api_base}/{self.api_version}/interactions/{interaction_id}", + f"{resolved_api_base}/{self.api_version}/interactions/{encode_path_segment(interaction_id)}", {}, ) @@ -269,7 +270,7 @@ class GoogleAIStudioInteractionsConfig(BaseInteractionsAPIConfig): if not GeminiModelInfo.get_api_key(litellm_params.api_key): raise ValueError("Google API key is required") return ( - f"{resolved_api_base}/{self.api_version}/interactions/{interaction_id}:cancel", + f"{resolved_api_base}/{self.api_version}/interactions/{encode_path_segment(interaction_id)}:cancel", {}, ) diff --git a/litellm/llms/gemini/vector_stores/transformation.py b/litellm/llms/gemini/vector_stores/transformation.py index e6e8369643e..bffe2d9cc27 100644 --- a/litellm/llms/gemini/vector_stores/transformation.py +++ b/litellm/llms/gemini/vector_stores/transformation.py @@ -9,6 +9,7 @@ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Union import httpx +from litellm.llms.base_llm._url_utils import encode_url_path from litellm.llms.base_llm.vector_store.transformation import BaseVectorStoreConfig from litellm.llms.gemini.common_utils import ( GeminiError, @@ -137,7 +138,7 @@ class GeminiVectorStoreConfig(BaseVectorStoreConfig): api_key = litellm_params.get("api_key") or GeminiModelInfo.get_api_key() if not api_key: raise ValueError("GEMINI_API_KEY or GOOGLE_API_KEY is required") - url = f"{api_base}/models/{model}:generateContent" + url = f"{api_base}/models/{encode_url_path(model)}:generateContent" # Build file_search tool configuration (using snake_case as per Gemini docs) file_search_config: Dict[str, Any] = { diff --git a/litellm/llms/gemini/videos/transformation.py b/litellm/llms/gemini/videos/transformation.py index c7116940b22..f8e9447d1bc 100644 --- a/litellm/llms/gemini/videos/transformation.py +++ b/litellm/llms/gemini/videos/transformation.py @@ -7,6 +7,7 @@ from httpx._types import RequestFiles import litellm from litellm.constants import DEFAULT_GOOGLE_VIDEO_DURATION_SECONDS from litellm.images.utils import ImageEditRequestUtils +from litellm.llms.base_llm._url_utils import encode_url_path from litellm.llms.base_llm.videos.transformation import BaseVideoConfig from litellm.secret_managers.main import get_secret_str from litellm.types.llms.gemini import ( @@ -245,7 +246,7 @@ class GeminiVideoConfig(BaseVideoConfig): return api_base.rstrip("/") model_name = model.replace("gemini/", "") - url = f"{api_base.rstrip('/')}/v1beta/models/{model_name}:predictLongRunning" + url = f"{api_base.rstrip('/')}/v1beta/models/{encode_url_path(model_name)}:predictLongRunning" return url @@ -376,7 +377,7 @@ class GeminiVideoConfig(BaseVideoConfig): GET https://generativelanguage.googleapis.com/v1beta/{operation_name} """ operation_name = extract_original_video_id(video_id) - url = f"{api_base.rstrip('/')}/v1beta/{operation_name}" + url = f"{api_base.rstrip('/')}/v1beta/{encode_url_path(operation_name)}" params: Dict[str, Any] = {} return url, params @@ -451,7 +452,7 @@ class GeminiVideoConfig(BaseVideoConfig): """ operation_name = extract_original_video_id(video_id) - status_url = f"{api_base.rstrip('/')}/v1beta/{operation_name}" + status_url = f"{api_base.rstrip('/')}/v1beta/{encode_url_path(operation_name)}" client = litellm.module_level_client status_response = client.get(url=status_url, headers=headers) status_response.raise_for_status() diff --git a/litellm/llms/huggingface/chat/transformation.py b/litellm/llms/huggingface/chat/transformation.py index 557aa48550b..fc585401563 100644 --- a/litellm/llms/huggingface/chat/transformation.py +++ b/litellm/llms/huggingface/chat/transformation.py @@ -13,6 +13,7 @@ if TYPE_CHECKING: else: LoggingClass = Any +from litellm.llms.base_llm._url_utils import encode_url_path from litellm.llms.base_llm.chat.transformation import BaseLLMException from ...openai.chat.gpt_transformation import OpenAIGPTConfig @@ -114,7 +115,7 @@ class HuggingFaceChatConfig(OpenAIGPTConfig): if "/" in remaining: provider = first_part if provider == "hf-inference": - route = f"{provider}/models/{model}/v1/chat/completions" + route = f"{provider}/models/{encode_url_path(model)}/v1/chat/completions" elif provider == "novita": route = f"{provider}/v3/openai/chat/completions" elif provider == "fireworks-ai": diff --git a/litellm/llms/huggingface/common_utils.py b/litellm/llms/huggingface/common_utils.py index 9ab4367c9b3..bf97099f46d 100644 --- a/litellm/llms/huggingface/common_utils.py +++ b/litellm/llms/huggingface/common_utils.py @@ -4,6 +4,7 @@ from typing import Literal, Optional, Union import httpx +from litellm.llms.base_llm._url_utils import encode_url_path from litellm.llms.base_llm.chat.transformation import BaseLLMException HF_HUB_URL = "https://huggingface.co" @@ -76,7 +77,7 @@ def _fetch_inference_provider_mapping(model: str) -> dict: if os.getenv("HUGGINGFACE_API_KEY"): headers["Authorization"] = f"Bearer {os.getenv('HUGGINGFACE_API_KEY')}" - path = f"{HF_HUB_URL}/api/models/{model}" + path = f"{HF_HUB_URL}/api/models/{encode_url_path(model)}" params = {"expand": ["inferenceProviderMapping"]} try: diff --git a/litellm/llms/huggingface/embedding/handler.py b/litellm/llms/huggingface/embedding/handler.py index 226f6b2ebad..20bff49752a 100644 --- a/litellm/llms/huggingface/embedding/handler.py +++ b/litellm/llms/huggingface/embedding/handler.py @@ -6,6 +6,7 @@ import httpx import litellm from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj +from litellm.llms.base_llm._url_utils import encode_url_path from litellm.llms.custom_httpx.http_handler import ( AsyncHTTPHandler, HTTPHandler, @@ -40,7 +41,7 @@ def get_hf_task_embedding_for_model( ) http_client = HTTPHandler(concurrent_limit=1) - model_info = http_client.get(url=f"{api_base}/api/models/{model}") + model_info = http_client.get(url=f"{api_base}/api/models/{encode_url_path(model)}") model_info_dict = model_info.json() @@ -65,7 +66,9 @@ async def async_get_hf_task_embedding_for_model( llm_provider=litellm.LlmProviders.HUGGINGFACE, ) - model_info = await http_client.get(url=f"{api_base}/api/models/{model}") + model_info = await http_client.get( + url=f"{api_base}/api/models/{encode_url_path(model)}" + ) model_info_dict = model_info.json() diff --git a/litellm/llms/huggingface/embedding/transformation.py b/litellm/llms/huggingface/embedding/transformation.py index 88d42cfcdcc..5222a51a357 100644 --- a/litellm/llms/huggingface/embedding/transformation.py +++ b/litellm/llms/huggingface/embedding/transformation.py @@ -15,6 +15,7 @@ from litellm.litellm_core_utils.prompt_templates.factory import ( prompt_factory, ) from litellm.litellm_core_utils.streaming_handler import CustomStreamWrapper +from litellm.llms.base_llm._url_utils import encode_url_path from litellm.llms.base_llm.chat.transformation import BaseConfig, BaseLLMException from litellm.secret_managers.main import get_secret_str from litellm.types.llms.openai import AllMessageValues @@ -345,7 +346,9 @@ class HuggingFaceEmbeddingConfig(BaseConfig): elif "HUGGINGFACE_API_BASE" in os.environ: completion_url = os.getenv("HUGGINGFACE_API_BASE", "") else: - completion_url = f"https://api-inference.huggingface.co/models/{model}" + completion_url = ( + f"https://api-inference.huggingface.co/models/{encode_url_path(model)}" + ) return completion_url diff --git a/litellm/llms/manus/files/transformation.py b/litellm/llms/manus/files/transformation.py index 3381a5327e8..ba0e97bb42b 100644 --- a/litellm/llms/manus/files/transformation.py +++ b/litellm/llms/manus/files/transformation.py @@ -19,6 +19,7 @@ from openai.types.file_deleted import FileDeleted import litellm from litellm._logging import verbose_logger from litellm.litellm_core_utils.prompt_templates.common_utils import extract_file_data +from litellm.llms.base_llm._url_utils import encode_path_segment from litellm.llms.base_llm.chat.transformation import BaseLLMException from litellm.llms.base_llm.files.transformation import ( BaseFilesConfig, @@ -306,7 +307,7 @@ class ManusFilesConfig(BaseFilesConfig): optional_params=optional_params, litellm_params=litellm_params, ) - return f"{api_base}/{file_id}", {} + return f"{api_base}/{encode_path_segment(file_id)}", {} def transform_retrieve_file_response( self, @@ -336,7 +337,7 @@ class ManusFilesConfig(BaseFilesConfig): optional_params=optional_params, litellm_params=litellm_params, ) - return f"{api_base}/{file_id}", {} + return f"{api_base}/{encode_path_segment(file_id)}", {} def transform_delete_file_response( self, @@ -422,7 +423,7 @@ class ManusFilesConfig(BaseFilesConfig): optional_params=optional_params, litellm_params=litellm_params, ) - return f"{api_base}/{file_id}/content", {} + return f"{api_base}/{encode_path_segment(file_id)}/content", {} def transform_file_content_response( self, diff --git a/litellm/llms/manus/responses/transformation.py b/litellm/llms/manus/responses/transformation.py index 510c41304a8..0121f79cfc6 100644 --- a/litellm/llms/manus/responses/transformation.py +++ b/litellm/llms/manus/responses/transformation.py @@ -9,6 +9,7 @@ from litellm.litellm_core_utils.core_helpers import process_response_headers from litellm.litellm_core_utils.llm_response_utils.convert_dict_to_response import ( _safe_convert_created_field, ) +from litellm.llms.base_llm._url_utils import encode_path_segment from litellm.llms.openai.common_utils import OpenAIError from litellm.llms.openai.responses.transformation import OpenAIResponsesAPIConfig from litellm.secret_managers.main import get_secret_str @@ -270,7 +271,7 @@ class ManusResponsesAPIConfig(OpenAIResponsesAPIConfig): Reference: https://open.manus.im/docs/openai-compatibility """ - url = f"{api_base}/{response_id}" + url = f"{api_base}/{encode_path_segment(response_id)}" data: Dict = {} return url, data diff --git a/litellm/llms/nvidia_nim/rerank/transformation.py b/litellm/llms/nvidia_nim/rerank/transformation.py index bd5abac60c8..4e6291125fd 100644 --- a/litellm/llms/nvidia_nim/rerank/transformation.py +++ b/litellm/llms/nvidia_nim/rerank/transformation.py @@ -6,6 +6,7 @@ from typing_extensions import Required, TypedDict import litellm from litellm._uuid import uuid from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj +from litellm.llms.base_llm._url_utils import encode_url_path from litellm.llms.base_llm.chat.transformation import BaseLLMException from litellm.llms.base_llm.rerank.transformation import BaseRerankConfig from litellm.secret_managers.main import get_secret_str @@ -92,7 +93,7 @@ class NvidiaNimRerankConfig(BaseRerankConfig): # Strip nvidia_nim/ prefix from model name if present clean_model = self._get_clean_model_name(model) - return f"{api_base}/v1/retrieval/{clean_model}/reranking" + return f"{api_base}/v1/retrieval/{encode_url_path(clean_model)}/reranking" def get_supported_cohere_rerank_params(self, model: str) -> list: """ diff --git a/litellm/llms/openai/containers/transformation.py b/litellm/llms/openai/containers/transformation.py index 955b9f760d1..7eef36939fd 100644 --- a/litellm/llms/openai/containers/transformation.py +++ b/litellm/llms/openai/containers/transformation.py @@ -16,6 +16,7 @@ from litellm.types.containers.main import ( ) from litellm.types.router import GenericLiteLLMParams +from ...base_llm._url_utils import encode_path_segment from ...base_llm.containers.transformation import BaseContainerConfig from .utils import join_container_api_base_path @@ -198,7 +199,9 @@ class OpenAIContainerConfig(BaseContainerConfig): ) -> Tuple[str, Dict]: """Transform the OpenAI container retrieve request.""" # For container retrieve, we just need to construct the URL - url = join_container_api_base_path(api_base, f"/{container_id}") + url = join_container_api_base_path( + api_base, f"/{encode_path_segment(container_id)}" + ) # No additional data needed for GET request data: Dict[str, Any] = {} @@ -230,7 +233,9 @@ class OpenAIContainerConfig(BaseContainerConfig): - DELETE /v1/containers/{container_id} """ # Construct the URL for container delete - url = join_container_api_base_path(api_base, f"/{container_id}") + url = join_container_api_base_path( + api_base, f"/{encode_path_segment(container_id)}" + ) # No data needed for DELETE request data: Dict[str, Any] = {} @@ -267,7 +272,9 @@ class OpenAIContainerConfig(BaseContainerConfig): - GET /v1/containers/{container_id}/files """ # Construct the URL for container files - url = join_container_api_base_path(api_base, f"/{container_id}/files") + url = join_container_api_base_path( + api_base, f"/{encode_path_segment(container_id)}/files" + ) # Prepare query parameters params: Dict[str, Any] = {} @@ -312,7 +319,8 @@ class OpenAIContainerConfig(BaseContainerConfig): """ # Construct the URL for container file content url = join_container_api_base_path( - api_base, f"/{container_id}/files/{file_id}/content" + api_base, + f"/{encode_path_segment(container_id)}/files/{encode_path_segment(file_id)}/content", ) # No query parameters needed diff --git a/litellm/llms/openai/evals/transformation.py b/litellm/llms/openai/evals/transformation.py index c24dbf8637a..b56aeaeb312 100644 --- a/litellm/llms/openai/evals/transformation.py +++ b/litellm/llms/openai/evals/transformation.py @@ -7,6 +7,7 @@ from typing import Any, Dict, Optional, Tuple import httpx from litellm._logging import verbose_logger +from litellm.llms.base_llm._url_utils import encode_path_segment from litellm.llms.base_llm.evals.transformation import ( BaseEvalsAPIConfig, LiteLLMLoggingObj, @@ -76,7 +77,7 @@ class OpenAIEvalsConfig(BaseEvalsAPIConfig): api_base = "https://api.openai.com" if eval_id: - return f"{api_base}/v1/evals/{eval_id}" + return f"{api_base}/v1/evals/{encode_path_segment(eval_id)}" return f"{api_base}/v1/{endpoint}" def transform_create_eval_request( @@ -276,7 +277,7 @@ class OpenAIEvalsConfig(BaseEvalsAPIConfig): if litellm_params and litellm_params.api_base: api_base = litellm_params.api_base - url = f"{api_base}/v1/evals/{eval_id}/runs" + url = f"{api_base}/v1/evals/{encode_path_segment(eval_id)}/runs" # Build request body request_body = {k: v for k, v in create_request.items() if v is not None} @@ -310,7 +311,7 @@ class OpenAIEvalsConfig(BaseEvalsAPIConfig): if litellm_params and litellm_params.api_base: api_base = litellm_params.api_base - url = f"{api_base}/v1/evals/{eval_id}/runs" + url = f"{api_base}/v1/evals/{encode_path_segment(eval_id)}/runs" # Build query parameters query_params: Dict[str, Any] = {} @@ -350,7 +351,7 @@ class OpenAIEvalsConfig(BaseEvalsAPIConfig): headers: dict, ) -> Tuple[str, Dict]: """Transform get run request for OpenAI""" - url = f"{api_base}/v1/evals/{eval_id}/runs/{run_id}" + url = f"{api_base}/v1/evals/{encode_path_segment(eval_id)}/runs/{encode_path_segment(run_id)}" verbose_logger.debug("Get run request - URL: %s", url) @@ -376,7 +377,7 @@ class OpenAIEvalsConfig(BaseEvalsAPIConfig): headers: dict, ) -> Tuple[str, Dict, Dict]: """Transform cancel run request for OpenAI""" - url = f"{api_base}/v1/evals/{eval_id}/runs/{run_id}/cancel" + url = f"{api_base}/v1/evals/{encode_path_segment(eval_id)}/runs/{encode_path_segment(run_id)}/cancel" # Empty body for cancel request request_body: Dict[str, Any] = {} @@ -405,7 +406,7 @@ class OpenAIEvalsConfig(BaseEvalsAPIConfig): headers: dict, ) -> Tuple[str, Dict, Dict]: """Transform delete run request for OpenAI""" - url = f"{api_base}/v1/evals/{eval_id}/runs/{run_id}" + url = f"{api_base}/v1/evals/{encode_path_segment(eval_id)}/runs/{encode_path_segment(run_id)}" # Empty body for delete request request_body: Dict[str, Any] = {} diff --git a/litellm/llms/openai/responses/transformation.py b/litellm/llms/openai/responses/transformation.py index 87c502032cc..1a436a98fa7 100644 --- a/litellm/llms/openai/responses/transformation.py +++ b/litellm/llms/openai/responses/transformation.py @@ -10,6 +10,7 @@ from litellm.litellm_core_utils.core_helpers import process_response_headers from litellm.litellm_core_utils.llm_response_utils.convert_dict_to_response import ( _safe_convert_created_field, ) +from litellm.llms.base_llm._url_utils import encode_path_segment from litellm.llms.base_llm.responses.transformation import BaseResponsesAPIConfig from litellm.secret_managers.main import get_secret_str from litellm.types.llms.openai import * @@ -421,7 +422,7 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig): OpenAI API expects the following request - DELETE /v1/responses/{response_id} """ - url = f"{api_base}/{response_id}" + url = f"{api_base}/{encode_path_segment(response_id)}" data: Dict = {} return url, data @@ -457,7 +458,7 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig): OpenAI API expects the following request - GET /v1/responses/{response_id} """ - url = f"{api_base}/{response_id}" + url = f"{api_base}/{encode_path_segment(response_id)}" data: Dict = {} return url, data @@ -498,7 +499,7 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig): limit: int = 20, order: Literal["asc", "desc"] = "desc", ) -> Tuple[str, Dict]: - url = f"{api_base}/{response_id}/input_items" + url = f"{api_base}/{encode_path_segment(response_id)}/input_items" params: Dict[str, Any] = {} if after is not None: params["after"] = after @@ -540,7 +541,7 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig): OpenAI API expects the following request - POST /v1/responses/{response_id}/cancel """ - url = f"{api_base}/{response_id}/cancel" + url = f"{api_base}/{encode_path_segment(response_id)}/cancel" data: Dict = {} return url, data diff --git a/litellm/llms/openai/vector_store_files/transformation.py b/litellm/llms/openai/vector_store_files/transformation.py index cd5f10251bb..0926ae88b9c 100644 --- a/litellm/llms/openai/vector_store_files/transformation.py +++ b/litellm/llms/openai/vector_store_files/transformation.py @@ -3,6 +3,7 @@ from typing import Any, Dict, Optional, Tuple, cast import httpx import litellm +from litellm.llms.base_llm._url_utils import encode_path_segment from litellm.llms.base_llm.vector_store_files.transformation import ( BaseVectorStoreFilesConfig, ) @@ -98,7 +99,7 @@ class OpenAIVectorStoreFilesConfig(BaseVectorStoreFilesConfig): or "https://api.openai.com/v1" ) base_url = base_url.rstrip("/") - return f"{base_url}/vector_stores/{vector_store_id}/files" + return f"{base_url}/vector_stores/{encode_path_segment(vector_store_id)}/files" def transform_create_vector_store_file_request( self, @@ -163,7 +164,7 @@ class OpenAIVectorStoreFilesConfig(BaseVectorStoreFilesConfig): file_id: str, api_base: str, ) -> Tuple[str, Dict[str, Any]]: - return f"{api_base}/{file_id}", {} + return f"{api_base}/{encode_path_segment(file_id)}", {} def transform_retrieve_vector_store_file_response( self, @@ -186,7 +187,7 @@ class OpenAIVectorStoreFilesConfig(BaseVectorStoreFilesConfig): file_id: str, api_base: str, ) -> Tuple[str, Dict[str, Any]]: - return f"{api_base}/{file_id}/content", {} + return f"{api_base}/{encode_path_segment(file_id)}/content", {} def transform_retrieve_vector_store_file_content_response( self, @@ -218,7 +219,7 @@ class OpenAIVectorStoreFilesConfig(BaseVectorStoreFilesConfig): payload["attributes"] = filtered_attributes else: payload.pop("attributes", None) - return f"{api_base}/{file_id}", payload + return f"{api_base}/{encode_path_segment(file_id)}", payload def transform_update_vector_store_file_response( self, @@ -241,7 +242,7 @@ class OpenAIVectorStoreFilesConfig(BaseVectorStoreFilesConfig): file_id: str, api_base: str, ) -> Tuple[str, Dict[str, Any]]: - return f"{api_base}/{file_id}", {} + return f"{api_base}/{encode_path_segment(file_id)}", {} def transform_delete_vector_store_file_response( self, diff --git a/litellm/llms/openai/vector_stores/transformation.py b/litellm/llms/openai/vector_stores/transformation.py index c763ed1c8da..554289048b1 100644 --- a/litellm/llms/openai/vector_stores/transformation.py +++ b/litellm/llms/openai/vector_stores/transformation.py @@ -3,6 +3,7 @@ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Union, cast import httpx import litellm +from litellm.llms.base_llm._url_utils import encode_path_segment from litellm.llms.base_llm.vector_store.transformation import BaseVectorStoreConfig from litellm.secret_managers.main import get_secret_str from litellm.types.router import GenericLiteLLMParams @@ -107,7 +108,7 @@ class OpenAIVectorStoreConfig(BaseVectorStoreConfig): litellm_logging_obj: LiteLLMLoggingObj, litellm_params: dict, ) -> Tuple[str, Dict]: - url = f"{api_base}/{vector_store_id}/search" + url = f"{api_base}/{encode_path_segment(vector_store_id)}/search" typed_request_body = VectorStoreSearchRequest( query=query, filters=vector_store_search_optional_params.get("filters", None), diff --git a/litellm/llms/openai/videos/transformation.py b/litellm/llms/openai/videos/transformation.py index 61baa56949c..1bdbbb6ef0a 100644 --- a/litellm/llms/openai/videos/transformation.py +++ b/litellm/llms/openai/videos/transformation.py @@ -1,11 +1,13 @@ import mimetypes from io import BufferedReader, BytesIO from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Union, cast +from urllib.parse import quote import httpx from httpx._types import RequestFiles import litellm +from litellm.llms.base_llm._url_utils import encode_path_segment from litellm.llms.base_llm.videos.transformation import BaseVideoConfig from litellm.llms.openai.image_edit.transformation import ImageEditRequestUtils from litellm.secret_managers.main import get_secret_str @@ -222,9 +224,9 @@ class OpenAIVideoConfig(BaseVideoConfig): original_video_id = extract_original_video_id(video_id) # Construct the URL for video content download - url = f"{api_base.rstrip('/')}/{original_video_id}/content" + url = f"{api_base.rstrip('/')}/{encode_path_segment(original_video_id)}/content" if variant is not None: - url = f"{url}?variant={variant}" + url = f"{url}?variant={quote(str(variant), safe='')}" # No additional data needed for GET content request data: Dict[str, Any] = {} @@ -249,7 +251,7 @@ class OpenAIVideoConfig(BaseVideoConfig): original_video_id = extract_original_video_id(video_id) # Construct the URL for video remix - url = f"{api_base.rstrip('/')}/{original_video_id}/remix" + url = f"{api_base.rstrip('/')}/{encode_path_segment(original_video_id)}/remix" # Prepare the request data data = {"prompt": prompt} @@ -393,7 +395,7 @@ class OpenAIVideoConfig(BaseVideoConfig): original_video_id = extract_original_video_id(video_id) # Construct the URL for video delete - url = f"{api_base.rstrip('/')}/{original_video_id}" + url = f"{api_base.rstrip('/')}/{encode_path_segment(original_video_id)}" # No data needed for DELETE request data: Dict[str, Any] = {} @@ -429,7 +431,7 @@ class OpenAIVideoConfig(BaseVideoConfig): original_video_id = extract_original_video_id(video_id) # For video retrieve, we just need to construct the URL - url = f"{api_base.rstrip('/')}/{original_video_id}" + url = f"{api_base.rstrip('/')}/{encode_path_segment(original_video_id)}" # No additional data needed for GET request data: Dict[str, Any] = {} @@ -494,7 +496,7 @@ class OpenAIVideoConfig(BaseVideoConfig): litellm_params: GenericLiteLLMParams, headers: dict, ) -> Tuple[str, Dict]: - url = f"{api_base.rstrip('/')}/characters/{character_id}" + url = f"{api_base.rstrip('/')}/characters/{encode_path_segment(character_id)}" return url, {} def transform_video_get_character_response( diff --git a/litellm/llms/pg_vector/vector_stores/transformation.py b/litellm/llms/pg_vector/vector_stores/transformation.py index ba87a8f2b01..c12d82a507d 100644 --- a/litellm/llms/pg_vector/vector_stores/transformation.py +++ b/litellm/llms/pg_vector/vector_stores/transformation.py @@ -1,5 +1,6 @@ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Union +from litellm.llms.base_llm._url_utils import encode_path_segment from litellm.llms.openai.vector_stores.transformation import OpenAIVectorStoreConfig from litellm.secret_managers.main import get_secret_str from litellm.types.router import GenericLiteLLMParams @@ -81,7 +82,7 @@ class PGVectorStoreConfig(OpenAIVectorStoreConfig): litellm_logging_obj: LiteLLMLoggingObj, litellm_params: dict, ) -> Tuple[str, Dict]: - url = f"{api_base}/{vector_store_id}/search" + url = f"{api_base}/{encode_path_segment(vector_store_id)}/search" _, request_body = super().transform_search_vector_store_request( vector_store_id=vector_store_id, query=query, diff --git a/litellm/llms/predibase/chat/handler.py b/litellm/llms/predibase/chat/handler.py index 79936764acd..fd14ee6ea27 100644 --- a/litellm/llms/predibase/chat/handler.py +++ b/litellm/llms/predibase/chat/handler.py @@ -17,6 +17,7 @@ from litellm.litellm_core_utils.prompt_templates.factory import ( custom_prompt, prompt_factory, ) +from litellm.llms.base_llm._url_utils import encode_path_segment from litellm.llms.custom_httpx.http_handler import ( AsyncHTTPHandler, get_async_httpx_client, @@ -254,7 +255,7 @@ class PredibaseChatCompletion: elif "PREDIBASE_API_BASE" in os.environ: base_url = os.getenv("PREDIBASE_API_BASE", "") - completion_url = f"{base_url}/{tenant_id}/deployments/v2/llms/{model}" + completion_url = f"{base_url}/{encode_path_segment(tenant_id)}/deployments/v2/llms/{encode_path_segment(model)}" if optional_params.get("stream", False) is True: completion_url += "/generate_stream" diff --git a/litellm/llms/ragflow/chat/transformation.py b/litellm/llms/ragflow/chat/transformation.py index d49a5fd370f..0c6191d342e 100644 --- a/litellm/llms/ragflow/chat/transformation.py +++ b/litellm/llms/ragflow/chat/transformation.py @@ -13,6 +13,7 @@ Model name format: from typing import List, Optional, Tuple import litellm +from litellm.llms.base_llm._url_utils import encode_path_segment from litellm.llms.openai.openai import OpenAIConfig from litellm.secret_managers.main import get_secret, get_secret_str from litellm.types.llms.openai import AllMessageValues @@ -126,14 +127,11 @@ class RAGFlowConfig(OpenAIConfig): api_base = api_base[:-3] # Remove /v1 # Construct the RAGFlow-specific path + encoded_entity_id = encode_path_segment(entity_id) if endpoint_type == "chat": - path = f"/api/v1/chats_openai/{entity_id}/chat/completions" + path = f"/api/v1/chats_openai/{encoded_entity_id}/chat/completions" else: # agent - path = f"/api/v1/agents_openai/{entity_id}/chat/completions" - - # Ensure path starts with / - if not path.startswith("/"): - path = "/" + path + path = f"/api/v1/agents_openai/{encoded_entity_id}/chat/completions" return f"{api_base}{path}" diff --git a/litellm/llms/runwayml/image_generation/transformation.py b/litellm/llms/runwayml/image_generation/transformation.py index 448dcd4a67b..32a36e4f518 100644 --- a/litellm/llms/runwayml/image_generation/transformation.py +++ b/litellm/llms/runwayml/image_generation/transformation.py @@ -9,6 +9,7 @@ from litellm.constants import ( RUNWAYML_DEFAULT_API_VERSION, RUNWAYML_POLLING_TIMEOUT, ) +from litellm.llms.base_llm._url_utils import encode_path_segment from litellm.llms.base_llm.image_generation.transformation import ( BaseImageGenerationConfig, ) @@ -222,7 +223,7 @@ class RunwayMLImageGenerationConfig(BaseImageGenerationConfig): # Build task status URL api_base = api_base.rstrip("/") - task_url = f"{api_base}/v1/tasks/{task_id}" + task_url = f"{api_base}/v1/tasks/{encode_path_segment(task_id)}" verbose_logger.debug(f"Polling RunwayML task: {task_url}") @@ -271,7 +272,7 @@ class RunwayMLImageGenerationConfig(BaseImageGenerationConfig): # Build task status URL api_base = api_base.rstrip("/") - task_url = f"{api_base}/v1/tasks/{task_id}" + task_url = f"{api_base}/v1/tasks/{encode_path_segment(task_id)}" verbose_logger.debug(f"Polling RunwayML task (async): {task_url}") diff --git a/litellm/llms/runwayml/text_to_speech/transformation.py b/litellm/llms/runwayml/text_to_speech/transformation.py index 314a538f7c5..6f76d9b0e1e 100644 --- a/litellm/llms/runwayml/text_to_speech/transformation.py +++ b/litellm/llms/runwayml/text_to_speech/transformation.py @@ -16,6 +16,7 @@ from litellm.constants import ( RUNWAYML_DEFAULT_API_VERSION, RUNWAYML_POLLING_TIMEOUT, ) +from litellm.llms.base_llm._url_utils import encode_path_segment from litellm.llms.base_llm.text_to_speech.transformation import ( BaseTextToSpeechConfig, TextToSpeechRequestData, @@ -312,7 +313,7 @@ class RunwayMLTextToSpeechConfig(BaseTextToSpeechConfig): # Build task status URL api_base = api_base.rstrip("/") - task_url = f"{api_base}/v1/tasks/{task_id}" + task_url = f"{api_base}/v1/tasks/{encode_path_segment(task_id)}" verbose_logger.debug(f"Polling RunwayML TTS task: {task_url}") @@ -360,7 +361,7 @@ class RunwayMLTextToSpeechConfig(BaseTextToSpeechConfig): # Build task status URL api_base = api_base.rstrip("/") - task_url = f"{api_base}/v1/tasks/{task_id}" + task_url = f"{api_base}/v1/tasks/{encode_path_segment(task_id)}" verbose_logger.debug(f"Polling RunwayML TTS task (async): {task_url}") diff --git a/litellm/llms/runwayml/videos/transformation.py b/litellm/llms/runwayml/videos/transformation.py index 8377dea952e..d43193b8226 100644 --- a/litellm/llms/runwayml/videos/transformation.py +++ b/litellm/llms/runwayml/videos/transformation.py @@ -6,6 +6,7 @@ from httpx._types import RequestFiles import litellm from litellm.constants import RUNWAYML_DEFAULT_API_VERSION +from litellm.llms.base_llm._url_utils import encode_path_segment from litellm.llms.base_llm.chat.transformation import BaseLLMException from litellm.llms.base_llm.videos.transformation import BaseVideoConfig from litellm.llms.custom_httpx.http_handler import ( @@ -336,7 +337,7 @@ class RunwayMLVideoConfig(BaseVideoConfig): original_video_id = extract_original_video_id(video_id) # Get task status to retrieve video URL - url = f"{api_base}/tasks/{original_video_id}" + url = f"{api_base}/tasks/{encode_path_segment(original_video_id)}" params: Dict[str, Any] = {} @@ -497,7 +498,7 @@ class RunwayMLVideoConfig(BaseVideoConfig): original_video_id = extract_original_video_id(video_id) # Construct the URL for task cancellation - url = f"{api_base}/tasks/{original_video_id}/cancel" + url = f"{api_base}/tasks/{encode_path_segment(original_video_id)}/cancel" data: Dict[str, Any] = {} @@ -535,7 +536,7 @@ class RunwayMLVideoConfig(BaseVideoConfig): original_video_id = extract_original_video_id(video_id) # Construct the full URL for task status retrieval - url = f"{api_base}/tasks/{original_video_id}" + url = f"{api_base}/tasks/{encode_path_segment(original_video_id)}" # Empty dict for GET request (no body) data: Dict[str, Any] = {} diff --git a/litellm/llms/vertex_ai/batches/handler.py b/litellm/llms/vertex_ai/batches/handler.py index 028e02eb0ca..60d9dc13536 100644 --- a/litellm/llms/vertex_ai/batches/handler.py +++ b/litellm/llms/vertex_ai/batches/handler.py @@ -4,6 +4,7 @@ from typing import Any, Coroutine, Dict, Optional, Union import httpx import litellm +from litellm.llms.base_llm._url_utils import encode_path_segment from litellm.llms.custom_httpx.http_handler import ( _get_httpx_client, get_async_httpx_client, @@ -169,7 +170,7 @@ class VertexAIBatchPrediction(VertexLLM): ) # Append batch_id to the URL - default_api_base = f"{default_api_base}/{batch_id}" + default_api_base = f"{default_api_base}/{encode_path_segment(batch_id)}" if len(default_api_base.split(":")) > 1: endpoint = default_api_base.split(":")[-1] @@ -401,7 +402,9 @@ class VertexAIBatchPrediction(VertexLLM): vertex_project=vertex_project or project_id, ) - retrieve_api_base_default = f"{default_api_base}/{batch_id}" + retrieve_api_base_default = ( + f"{default_api_base}/{encode_path_segment(batch_id)}" + ) cancel_api_base_default = f"{retrieve_api_base_default}:cancel" _, api_base = self._check_custom_proxy( diff --git a/litellm/llms/vertex_ai/common_utils.py b/litellm/llms/vertex_ai/common_utils.py index ccd4d4f2934..d2f97d6bc3e 100644 --- a/litellm/llms/vertex_ai/common_utils.py +++ b/litellm/llms/vertex_ai/common_utils.py @@ -9,6 +9,7 @@ import litellm from litellm._logging import verbose_logger from litellm.constants import DEFAULT_MAX_RECURSE_DEPTH from litellm.litellm_core_utils.prompt_templates.common_utils import unpack_defs +from litellm.llms.base_llm._url_utils import encode_path_segment, encode_url_path from litellm.llms.base_llm.base_utils import BaseLLMModelInfo, BaseTokenCounter from litellm.llms.base_llm.chat.transformation import BaseLLMException from litellm.types.llms.openai import AllMessageValues @@ -275,11 +276,14 @@ def _get_embedding_url( endpoint = "embedContent" if uses_embed_content else "predict" base_url = get_vertex_base_url(vertex_location) + proj = encode_path_segment(vertex_project) + loc = encode_path_segment(vertex_location) + model_seg = encode_url_path(model) if model.isdigit(): - url = f"{base_url}/{vertex_api_version}/projects/{vertex_project}/locations/{vertex_location}/endpoints/{model}:{endpoint}" + url = f"{base_url}/{vertex_api_version}/projects/{proj}/locations/{loc}/endpoints/{model_seg}:{endpoint}" else: - url = f"{base_url}/v1/projects/{vertex_project}/locations/{vertex_location}/publishers/google/models/{model}:{endpoint}" + url = f"{base_url}/v1/projects/{proj}/locations/{loc}/publishers/google/models/{model_seg}:{endpoint}" return url, endpoint @@ -297,6 +301,10 @@ def _get_vertex_url( model = litellm.VertexGeminiConfig.get_model_for_vertex_ai_url(model=model) + proj = encode_path_segment(vertex_project) + loc = encode_path_segment(vertex_location) + model_seg = encode_url_path(model) + if mode == "chat": ### SET RUNTIME ENDPOINT ### endpoint = "generateContent" @@ -310,10 +318,10 @@ def _get_vertex_url( # send to this url: url = f"{base_url}/{version}/projects/{vertex_project}/locations/{vertex_location}/endpoints/{model}:{endpoint}" if model.isdigit(): # It's a fine-tuned Gemini model - use endpoints/ path - url = f"{base_url}/{vertex_api_version}/projects/{vertex_project}/locations/{vertex_location}/endpoints/{model}:{endpoint}" + url = f"{base_url}/{vertex_api_version}/projects/{proj}/locations/{loc}/endpoints/{model_seg}:{endpoint}" else: # Regular model - use publishers/google/models/ path - url = f"{base_url}/{vertex_api_version}/projects/{vertex_project}/locations/{vertex_location}/publishers/google/models/{model}:{endpoint}" + url = f"{base_url}/{vertex_api_version}/projects/{proj}/locations/{loc}/publishers/google/models/{model_seg}:{endpoint}" if stream is True: url += "?alt=sse" @@ -329,14 +337,14 @@ def _get_vertex_url( base_url = get_vertex_base_url(vertex_location) if model.isdigit(): # Numeric model -> custom endpoint - url = f"{base_url}/{vertex_api_version}/projects/{vertex_project}/locations/{vertex_location}/endpoints/{model}:{endpoint}" + url = f"{base_url}/{vertex_api_version}/projects/{proj}/locations/{loc}/endpoints/{model_seg}:{endpoint}" else: # Regular model -> publisher model - url = f"{base_url}/v1/projects/{vertex_project}/locations/{vertex_location}/publishers/google/models/{model}:{endpoint}" + url = f"{base_url}/v1/projects/{proj}/locations/{loc}/publishers/google/models/{model_seg}:{endpoint}" elif mode == "count_tokens": endpoint = "countTokens" base_url = get_vertex_base_url(vertex_location) - url = f"{base_url}/{vertex_api_version}/projects/{vertex_project}/locations/{vertex_location}/publishers/google/models/{model}:{endpoint}" + url = f"{base_url}/{vertex_api_version}/projects/{proj}/locations/{loc}/publishers/google/models/{model_seg}:{endpoint}" if not url or not endpoint: raise ValueError(f"Unable to get vertex url/endpoint for mode: {mode}") return url, endpoint @@ -357,7 +365,7 @@ def _get_gemini_url( VertexGeminiConfig, ) - _gemini_model_name = "models/{}".format(model) + _gemini_model_name = "models/{}".format(encode_url_path(model)) api_version = ( "v1alpha" if VertexGeminiConfig._is_gemini_3_or_newer(model) else "v1beta" ) diff --git a/litellm/llms/vertex_ai/image_edit/vertex_gemini_transformation.py b/litellm/llms/vertex_ai/image_edit/vertex_gemini_transformation.py index de7f234a861..1a9a23dea8b 100644 --- a/litellm/llms/vertex_ai/image_edit/vertex_gemini_transformation.py +++ b/litellm/llms/vertex_ai/image_edit/vertex_gemini_transformation.py @@ -9,6 +9,7 @@ from httpx._types import RequestFiles import litellm from litellm.images.utils import ImageEditRequestUtils +from litellm.llms.base_llm._url_utils import encode_path_segment, encode_url_path from litellm.llms.base_llm.image_edit.transformation import BaseImageEditConfig from litellm.llms.vertex_ai.common_utils import get_vertex_base_url from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import VertexLLM @@ -161,7 +162,7 @@ class VertexAIGeminiImageEditConfig(BaseImageEditConfig, VertexLLM): base_url = get_vertex_base_url(vertex_location) - return f"{base_url}/v1/projects/{vertex_project}/locations/{vertex_location}/publishers/google/models/{model_name}:generateContent" + return f"{base_url}/v1/projects/{encode_path_segment(vertex_project)}/locations/{encode_path_segment(vertex_location)}/publishers/google/models/{encode_url_path(model_name)}:generateContent" def transform_image_edit_request( # type: ignore[override] self, diff --git a/litellm/llms/vertex_ai/image_edit/vertex_imagen_transformation.py b/litellm/llms/vertex_ai/image_edit/vertex_imagen_transformation.py index 3eb039614fd..bb51070f667 100644 --- a/litellm/llms/vertex_ai/image_edit/vertex_imagen_transformation.py +++ b/litellm/llms/vertex_ai/image_edit/vertex_imagen_transformation.py @@ -10,6 +10,7 @@ from httpx._types import RequestFiles import litellm from litellm.constants import DEFAULT_MAX_RECURSE_DEPTH +from litellm.llms.base_llm._url_utils import encode_path_segment, encode_url_path from litellm.llms.base_llm.image_edit.transformation import BaseImageEditConfig from litellm.llms.vertex_ai.common_utils import get_vertex_base_url from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import VertexLLM @@ -161,7 +162,7 @@ class VertexAIImagenImageEditConfig(BaseImageEditConfig, VertexLLM): else: base_url = get_vertex_base_url(vertex_location) - return f"{base_url}/v1/projects/{vertex_project}/locations/{vertex_location}/publishers/google/models/{model_name}:predict" + return f"{base_url}/v1/projects/{encode_path_segment(vertex_project)}/locations/{encode_path_segment(vertex_location)}/publishers/google/models/{encode_url_path(model_name)}:predict" def transform_image_edit_request( # type: ignore[override] self, diff --git a/litellm/llms/vertex_ai/image_generation/vertex_gemini_transformation.py b/litellm/llms/vertex_ai/image_generation/vertex_gemini_transformation.py index f4bda8d1bed..b1ba3d6f8d5 100644 --- a/litellm/llms/vertex_ai/image_generation/vertex_gemini_transformation.py +++ b/litellm/llms/vertex_ai/image_generation/vertex_gemini_transformation.py @@ -4,6 +4,7 @@ from typing import TYPE_CHECKING, Any, Dict, List, Optional import httpx import litellm +from litellm.llms.base_llm._url_utils import encode_path_segment, encode_url_path from litellm.llms.base_llm.image_generation.transformation import ( BaseImageGenerationConfig, ) @@ -162,7 +163,7 @@ class VertexAIGeminiImageGenerationConfig(BaseImageGenerationConfig, VertexLLM): base_url = get_vertex_base_url(vertex_location) - return f"{base_url}/v1/projects/{vertex_project}/locations/{vertex_location}/publishers/google/models/{model_name}:generateContent" + return f"{base_url}/v1/projects/{encode_path_segment(vertex_project)}/locations/{encode_path_segment(vertex_location)}/publishers/google/models/{encode_url_path(model_name)}:generateContent" def validate_environment( self, diff --git a/litellm/llms/vertex_ai/image_generation/vertex_imagen_transformation.py b/litellm/llms/vertex_ai/image_generation/vertex_imagen_transformation.py index 1c7696d55a2..65051be1de0 100644 --- a/litellm/llms/vertex_ai/image_generation/vertex_imagen_transformation.py +++ b/litellm/llms/vertex_ai/image_generation/vertex_imagen_transformation.py @@ -4,6 +4,7 @@ from typing import TYPE_CHECKING, Any, List, Optional import httpx import litellm +from litellm.llms.base_llm._url_utils import encode_path_segment, encode_url_path from litellm.llms.base_llm.image_generation.transformation import ( BaseImageGenerationConfig, ) @@ -148,7 +149,7 @@ class VertexAIImagenImageGenerationConfig(BaseImageGenerationConfig, VertexLLM): base_url = get_vertex_base_url(vertex_location) - return f"{base_url}/v1/projects/{vertex_project}/locations/{vertex_location}/publishers/google/models/{model_name}:predict" + return f"{base_url}/v1/projects/{encode_path_segment(vertex_project)}/locations/{encode_path_segment(vertex_location)}/publishers/google/models/{encode_url_path(model_name)}:predict" def validate_environment( self, diff --git a/litellm/llms/vertex_ai/ocr/deepseek_transformation.py b/litellm/llms/vertex_ai/ocr/deepseek_transformation.py index 516ee03ba55..c61e529cb0f 100644 --- a/litellm/llms/vertex_ai/ocr/deepseek_transformation.py +++ b/litellm/llms/vertex_ai/ocr/deepseek_transformation.py @@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, Any, Dict, Optional import httpx from litellm._logging import verbose_logger +from litellm.llms.base_llm._url_utils import encode_path_segment from litellm.llms.base_llm.ocr.transformation import ( BaseOCRConfig, DocumentType, @@ -125,7 +126,7 @@ class VertexAIDeepSeekOCRConfig(BaseOCRConfig): # Vertex AI DeepSeek OCR endpoint format # Format: https://{region}-aiplatform.googleapis.com/v1/projects/{project}/locations/{region}/endpoints/openapi/chat/completions - return f"{api_base}/v1/projects/{vertex_project}/locations/{vertex_location}/endpoints/openapi/chat/completions" + return f"{api_base}/v1/projects/{encode_path_segment(vertex_project)}/locations/{encode_path_segment(vertex_location)}/endpoints/openapi/chat/completions" def transform_ocr_request( self, diff --git a/litellm/llms/vertex_ai/ocr/transformation.py b/litellm/llms/vertex_ai/ocr/transformation.py index cbf15803132..4cc0ab8cbca 100644 --- a/litellm/llms/vertex_ai/ocr/transformation.py +++ b/litellm/llms/vertex_ai/ocr/transformation.py @@ -9,6 +9,7 @@ from litellm.litellm_core_utils.prompt_templates.image_handling import ( async_convert_url_to_base64, convert_url_to_base64, ) +from litellm.llms.base_llm._url_utils import encode_path_segment, encode_url_path from litellm.llms.base_llm.ocr.transformation import DocumentType, OCRRequestData from litellm.llms.mistral.ocr.transformation import MistralOCRConfig from litellm.llms.vertex_ai.common_utils import get_vertex_base_url @@ -121,7 +122,7 @@ class VertexAIOCRConfig(MistralOCRConfig): # Vertex AI OCR endpoint format for Mistral publisher # Format: https://{region}-aiplatform.googleapis.com/v1/projects/{project}/locations/{region}/publishers/mistralai/models/{model}:rawPredict - return f"{api_base}/v1/projects/{vertex_project}/locations/{vertex_location}/publishers/mistralai/models/{model}:rawPredict" + return f"{api_base}/v1/projects/{encode_path_segment(vertex_project)}/locations/{encode_path_segment(vertex_location)}/publishers/mistralai/models/{encode_url_path(model)}:rawPredict" def _convert_url_to_data_uri_sync(self, url: str) -> str: """ diff --git a/litellm/llms/vertex_ai/rag_engine/transformation.py b/litellm/llms/vertex_ai/rag_engine/transformation.py index ed5154bbdff..74f59d3cfcf 100644 --- a/litellm/llms/vertex_ai/rag_engine/transformation.py +++ b/litellm/llms/vertex_ai/rag_engine/transformation.py @@ -8,6 +8,7 @@ from typing import Any, Dict, Optional from litellm._logging import verbose_logger from litellm.constants import DEFAULT_CHUNK_OVERLAP, DEFAULT_CHUNK_SIZE +from litellm.llms.base_llm._url_utils import encode_path_segment from litellm.llms.vertex_ai.common_utils import get_vertex_base_url from litellm.llms.vertex_ai.vertex_llm_base import VertexBase from litellm.types.rag import RAGChunkingStrategy @@ -39,7 +40,7 @@ class VertexAIRAGTransformation(VertexBase): Vertex AI RAG Engine primarily uses gRPC-based SDK. """ base_url = get_vertex_base_url(vertex_location) - return f"{base_url}/v1/projects/{vertex_project}/locations/{vertex_location}/ragCorpora/{corpus_id}:importRagFiles" + return f"{base_url}/v1/projects/{encode_path_segment(vertex_project)}/locations/{encode_path_segment(vertex_location)}/ragCorpora/{encode_path_segment(corpus_id)}:importRagFiles" def get_retrieve_contexts_url( self, @@ -48,7 +49,7 @@ class VertexAIRAGTransformation(VertexBase): ) -> str: """Get the URL for retrieving contexts (search).""" base_url = get_vertex_base_url(vertex_location) - return f"{base_url}/v1/projects/{vertex_project}/locations/{vertex_location}:retrieveContexts" + return f"{base_url}/v1/projects/{encode_path_segment(vertex_project)}/locations/{encode_path_segment(vertex_location)}:retrieveContexts" def transform_chunking_strategy_to_vertex_format( self, diff --git a/litellm/llms/vertex_ai/vertex_ai_partner_models/count_tokens/handler.py b/litellm/llms/vertex_ai/vertex_ai_partner_models/count_tokens/handler.py index 3a3ab2e2465..1a3122c24c1 100644 --- a/litellm/llms/vertex_ai/vertex_ai_partner_models/count_tokens/handler.py +++ b/litellm/llms/vertex_ai/vertex_ai_partner_models/count_tokens/handler.py @@ -8,6 +8,7 @@ their respective publisher-specific count-tokens endpoints. from typing import Any, Dict, Optional +from litellm.llms.base_llm._url_utils import encode_path_segment from litellm.llms.custom_httpx.http_handler import get_async_httpx_client from litellm.llms.vertex_ai.common_utils import get_vertex_base_url from litellm.llms.vertex_ai.vertex_llm_base import VertexBase @@ -72,10 +73,7 @@ class VertexAIPartnerModelsTokenCounter(VertexBase): # Construct the count-tokens endpoint # Format: /v1/projects/{project}/locations/{location}/publishers/{publisher}/models/count-tokens:rawPredict - endpoint = ( - f"{base_url}/v1/projects/{project_id}/locations/{vertex_location}/" - f"publishers/{publisher}/models/count-tokens:rawPredict" - ) + endpoint = f"{base_url}/v1/projects/{encode_path_segment(project_id)}/locations/{encode_path_segment(vertex_location)}/publishers/{encode_path_segment(publisher)}/models/count-tokens:rawPredict" return endpoint diff --git a/litellm/llms/vertex_ai/vertex_llm_base.py b/litellm/llms/vertex_ai/vertex_llm_base.py index 6f687dae7e8..0e6544983ba 100644 --- a/litellm/llms/vertex_ai/vertex_llm_base.py +++ b/litellm/llms/vertex_ai/vertex_llm_base.py @@ -11,6 +11,7 @@ from typing import TYPE_CHECKING, Any, Dict, Literal, Optional, Tuple import litellm from litellm._logging import verbose_logger from litellm.litellm_core_utils.asyncify import asyncify +from litellm.llms.base_llm._url_utils import encode_path_segment, encode_url_path from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler from litellm.secret_managers.main import get_secret_str from litellm.types.llms.vertex_ai import VERTEX_CREDENTIALS_TYPES, VertexPartnerProvider @@ -273,23 +274,20 @@ class VertexBase: if api_base is None: api_base = get_vertex_base_url(vertex_location) + proj = encode_path_segment(vertex_project) + loc = encode_path_segment(vertex_location) + model_seg = encode_url_path(model) if partner == VertexPartnerProvider.llama: - return f"{api_base}/v1/projects/{vertex_project}/locations/{vertex_location}/endpoints/openapi/chat/completions" + return f"{api_base}/v1/projects/{proj}/locations/{loc}/endpoints/openapi/chat/completions" elif partner == VertexPartnerProvider.mistralai: - if stream: - return f"{api_base}/v1/projects/{vertex_project}/locations/{vertex_location}/publishers/mistralai/models/{model}:streamRawPredict" - else: - return f"{api_base}/v1/projects/{vertex_project}/locations/{vertex_location}/publishers/mistralai/models/{model}:rawPredict" + action = "streamRawPredict" if stream else "rawPredict" + return f"{api_base}/v1/projects/{proj}/locations/{loc}/publishers/mistralai/models/{model_seg}:{action}" elif partner == VertexPartnerProvider.ai21: - if stream: - return f"{api_base}/v1beta1/projects/{vertex_project}/locations/{vertex_location}/publishers/ai21/models/{model}:streamRawPredict" - else: - return f"{api_base}/v1beta1/projects/{vertex_project}/locations/{vertex_location}/publishers/ai21/models/{model}:rawPredict" + action = "streamRawPredict" if stream else "rawPredict" + return f"{api_base}/v1beta1/projects/{proj}/locations/{loc}/publishers/ai21/models/{model_seg}:{action}" elif partner == VertexPartnerProvider.claude: - if stream: - return f"{api_base}/v1/projects/{vertex_project}/locations/{vertex_location}/publishers/anthropic/models/{model}:streamRawPredict" - else: - return f"{api_base}/v1/projects/{vertex_project}/locations/{vertex_location}/publishers/anthropic/models/{model}:rawPredict" + action = "streamRawPredict" if stream else "rawPredict" + return f"{api_base}/v1/projects/{proj}/locations/{loc}/publishers/anthropic/models/{model_seg}:{action}" def get_complete_vertex_url( self, diff --git a/litellm/llms/vertex_ai/videos/transformation.py b/litellm/llms/vertex_ai/videos/transformation.py index ed6176cef05..10dafeac041 100644 --- a/litellm/llms/vertex_ai/videos/transformation.py +++ b/litellm/llms/vertex_ai/videos/transformation.py @@ -14,6 +14,7 @@ from httpx._types import RequestFiles from litellm.constants import DEFAULT_GOOGLE_VIDEO_DURATION_SECONDS from litellm.images.utils import ImageEditRequestUtils +from litellm.llms.base_llm._url_utils import encode_path_segment, encode_url_path from litellm.llms.base_llm.videos.transformation import BaseVideoConfig from litellm.llms.vertex_ai.common_utils import ( _convert_vertex_datetime_to_openai_datetime, @@ -238,7 +239,7 @@ class VertexAIVideoConfig(BaseVideoConfig, VertexBase): else: base_url = get_vertex_base_url(vertex_location) - url = f"{base_url}/v1/projects/{vertex_project}/locations/{vertex_location}/publishers/google/models/{model_name}" + url = f"{base_url}/v1/projects/{encode_path_segment(vertex_project)}/locations/{encode_path_segment(vertex_location)}/publishers/google/models/{encode_url_path(model_name)}" return url @@ -406,7 +407,7 @@ class VertexAIVideoConfig(BaseVideoConfig, VertexBase): # Construct the full URL including model ID # URL format: https://LOCATION-aiplatform.googleapis.com/v1/projects/PROJECT/locations/LOCATION/publishers/google/models/MODEL:fetchPredictOperation # Strip trailing slashes from api_base and append model - url = f"{api_base.rstrip('/')}/{model}:fetchPredictOperation" + url = f"{api_base.rstrip('/')}/{encode_url_path(model)}:fetchPredictOperation" # Request body contains the operation name params = {"operationName": operation_name} diff --git a/litellm/llms/volcengine/responses/transformation.py b/litellm/llms/volcengine/responses/transformation.py index f6dda4dd25b..7d9a45eb402 100644 --- a/litellm/llms/volcengine/responses/transformation.py +++ b/litellm/llms/volcengine/responses/transformation.py @@ -20,6 +20,7 @@ from litellm.litellm_core_utils.core_helpers import process_response_headers from litellm.litellm_core_utils.llm_response_utils.convert_dict_to_response import ( _safe_convert_created_field, ) +from litellm.llms.base_llm._url_utils import encode_path_segment from litellm.llms.openai.responses.transformation import OpenAIResponsesAPIConfig from litellm.secret_managers.main import get_secret_str from litellm.types.llms.openai import ( @@ -300,7 +301,7 @@ class VolcEngineResponsesAPIConfig(OpenAIResponsesAPIConfig): litellm_params: GenericLiteLLMParams, headers: dict, ) -> Tuple[str, Dict]: - url = f"{api_base}/{response_id}" + url = f"{api_base}/{encode_path_segment(response_id)}" data: Dict = {} return url, data @@ -333,7 +334,7 @@ class VolcEngineResponsesAPIConfig(OpenAIResponsesAPIConfig): litellm_params: GenericLiteLLMParams, headers: dict, ) -> Tuple[str, Dict]: - url = f"{api_base}/{response_id}" + url = f"{api_base}/{encode_path_segment(response_id)}" data: Dict = {} return url, data @@ -372,7 +373,7 @@ class VolcEngineResponsesAPIConfig(OpenAIResponsesAPIConfig): limit: int = 20, order: Literal["asc", "desc"] = "desc", ) -> Tuple[str, Dict]: - url = f"{api_base}/{response_id}/input_items" + url = f"{api_base}/{encode_path_segment(response_id)}/input_items" params: Dict[str, Any] = {} if after is not None: params["after"] = after @@ -408,7 +409,7 @@ class VolcEngineResponsesAPIConfig(OpenAIResponsesAPIConfig): litellm_params: GenericLiteLLMParams, headers: dict, ) -> Tuple[str, Dict]: - url = f"{api_base}/{response_id}/cancel" + url = f"{api_base}/{encode_path_segment(response_id)}/cancel" data: Dict = {} return url, data diff --git a/litellm/rag/ingestion/gemini_ingestion.py b/litellm/rag/ingestion/gemini_ingestion.py index af6eb928e2c..d6aca5c3818 100644 --- a/litellm/rag/ingestion/gemini_ingestion.py +++ b/litellm/rag/ingestion/gemini_ingestion.py @@ -10,6 +10,7 @@ from __future__ import annotations from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, cast from litellm._logging import verbose_logger +from litellm.llms.base_llm._url_utils import encode_path_segment from litellm.llms.custom_httpx.http_handler import ( get_async_httpx_client, httpxSpecialProvider, @@ -231,7 +232,7 @@ class GeminiRAGIngestion(BaseRAGIngestion): # base_url is like: https://generativelanguage.googleapis.com/v1beta # We need: https://generativelanguage.googleapis.com/upload/v1beta/{store_id}:uploadToFileSearchStore api_base = base_url.replace("/v1beta", "") # Get base without version - url = f"{api_base}/upload/v1beta/{vector_store_id}:uploadToFileSearchStore" + url = f"{api_base}/upload/v1beta/{encode_path_segment(vector_store_id)}:uploadToFileSearchStore" # Build request body with chunking config and metadata if provided request_body: Dict[str, Any] = {"displayName": filename} diff --git a/tests/test_litellm/llms/base_llm/test_url_utils.py b/tests/test_litellm/llms/base_llm/test_url_utils.py new file mode 100644 index 00000000000..2364e723a80 --- /dev/null +++ b/tests/test_litellm/llms/base_llm/test_url_utils.py @@ -0,0 +1,710 @@ +"""Tests for ``encode_path_segment`` / ``encode_url_path`` and provider call sites.""" + +import pytest + +from litellm.llms.base_llm._url_utils import encode_path_segment, encode_url_path + + +SAMPLE_INPUT = "../../v1/messages/batches" +SAMPLE_INPUT_ENCODED = "..%2F..%2Fv1%2Fmessages%2Fbatches" + + +def _assert_input_is_encoded(url: str, prefix: str) -> None: + assert SAMPLE_INPUT_ENCODED in url + tail = url.split(prefix, 1)[1] + assert "../" not in tail, f"raw ``../`` after {prefix!r}: {tail!r}" + + +class TestEncodePathSegment: + def test_encodes_dot_slash_sequences(self): + assert encode_path_segment(SAMPLE_INPUT) == SAMPLE_INPUT_ENCODED + + def test_encodes_query_and_fragment(self): + assert encode_path_segment("file?admin=1") == "file%3Fadmin%3D1" + assert encode_path_segment("file#frag") == "file%23frag" + + def test_leaves_normal_ids_unchanged(self): + assert encode_path_segment("file-abc123") == "file-abc123" + assert encode_path_segment("file_abc123") == "file_abc123" + + def test_rejects_bare_dotdot(self): + with pytest.raises(ValueError): + encode_path_segment("..") + with pytest.raises(ValueError): + encode_path_segment(".") + + def test_rejects_none_and_empty(self): + with pytest.raises(ValueError, match="identifier is required"): + encode_path_segment(None) + with pytest.raises(ValueError, match="identifier is required"): + encode_path_segment("") + + +class TestEncodeUrlPath: + def test_preserves_legitimate_slashes_and_at(self): + assert ( + encode_url_path("@cf/meta/llama-3.1-8b-instruct") + == "@cf/meta/llama-3.1-8b-instruct" + ) + assert encode_url_path("google/gemma-3-4b-it") == "google/gemma-3-4b-it" + + def test_rejects_dotdot_segment(self): + with pytest.raises(ValueError): + encode_url_path("../../etc/passwd") + with pytest.raises(ValueError): + encode_url_path("@cf/../secret") + + def test_rejects_single_dot_segment(self): + with pytest.raises(ValueError): + encode_url_path("./secret") + + def test_rejects_empty_segments(self): + with pytest.raises(ValueError): + encode_url_path("foo//bar") + with pytest.raises(ValueError): + encode_url_path("/foo") + with pytest.raises(ValueError): + encode_url_path("foo/") + + def test_encodes_query_fragment_and_colon(self): + assert encode_url_path("model?x=1") == "model%3Fx%3D1" + assert encode_url_path("model#frag") == "model%23frag" + assert encode_url_path("evil.com:80/x") == "evil.com%3A80/x" + + def test_none_becomes_empty(self): + assert encode_url_path(None) == "" + + +class TestAnthropicFilesEncoding: + def _config(self): + from litellm.llms.anthropic.files.transformation import AnthropicFilesConfig + + return AnthropicFilesConfig() + + def test_retrieve(self): + url, _ = self._config().transform_retrieve_file_request( + file_id=SAMPLE_INPUT, + optional_params={}, + litellm_params={}, + ) + _assert_input_is_encoded(url, "/v1/files/") + + def test_delete(self): + url, _ = self._config().transform_delete_file_request( + file_id=SAMPLE_INPUT, + optional_params={}, + litellm_params={}, + ) + _assert_input_is_encoded(url, "/v1/files/") + + def test_content(self): + url, _ = self._config().transform_file_content_request( + file_content_request={"file_id": SAMPLE_INPUT}, + optional_params={}, + litellm_params={}, + ) + _assert_input_is_encoded(url, "/v1/files/") + + @pytest.mark.parametrize( + "method_name", + [ + "transform_retrieve_file_request", + "transform_delete_file_request", + ], + ) + def test_missing_file_id_raises(self, method_name): + with pytest.raises(ValueError, match="identifier is required"): + getattr(self._config(), method_name)( + file_id="", optional_params={}, litellm_params={} + ) + + def test_missing_file_id_content_raises(self): + with pytest.raises(ValueError, match="identifier is required"): + self._config().transform_file_content_request( + file_content_request={}, # no file_id + optional_params={}, + litellm_params={}, + ) + + +class TestAnthropicBatchesEncoding: + def test_retrieve_url(self): + from litellm.llms.anthropic.batches.transformation import AnthropicBatchesConfig + + url = AnthropicBatchesConfig().get_retrieve_batch_url( + api_base="https://api.anthropic.com", + batch_id=SAMPLE_INPUT, + optional_params={}, + litellm_params={}, + ) + _assert_input_is_encoded(url, "/batches/") + + +class TestAnthropicSkillsEncoding: + def test_get_complete_url(self): + from litellm.llms.anthropic.skills.transformation import AnthropicSkillsConfig + + url = AnthropicSkillsConfig().get_complete_url( + api_base="https://api.anthropic.com", + endpoint="skills", + skill_id=SAMPLE_INPUT, + ) + _assert_input_is_encoded(url, "/v1/skills/") + + +class TestOpenAIVideosEncoding: + def _config(self): + from litellm.llms.openai.videos.transformation import OpenAIVideoConfig + from litellm.types.router import GenericLiteLLMParams + + return OpenAIVideoConfig(), GenericLiteLLMParams() + + def test_content(self): + cfg, params = self._config() + url, _ = cfg.transform_video_content_request( + video_id=SAMPLE_INPUT, + api_base="https://api.openai.com/v1/videos", + litellm_params=params, + headers={}, + ) + _assert_input_is_encoded(url, "/v1/videos/") + + def test_content_variant_is_encoded(self): + cfg, params = self._config() + url, _ = cfg.transform_video_content_request( + video_id="vid_ok", + api_base="https://api.openai.com/v1/videos", + litellm_params=params, + headers={}, + variant="bad&inject=1", + ) + assert "?variant=bad%26inject%3D1" in url + + def test_delete(self): + cfg, params = self._config() + url, _ = cfg.transform_video_delete_request( + video_id=SAMPLE_INPUT, + api_base="https://api.openai.com/v1/videos", + litellm_params=params, + headers={}, + ) + _assert_input_is_encoded(url, "/v1/videos/") + + def test_status_retrieve(self): + cfg, params = self._config() + url, _ = cfg.transform_video_status_retrieve_request( + video_id=SAMPLE_INPUT, + api_base="https://api.openai.com/v1/videos", + litellm_params=params, + headers={}, + ) + _assert_input_is_encoded(url, "/v1/videos/") + + def test_remix(self): + cfg, params = self._config() + url, _ = cfg.transform_video_remix_request( + video_id=SAMPLE_INPUT, + prompt="x", + api_base="https://api.openai.com/v1/videos", + litellm_params=params, + headers={}, + ) + _assert_input_is_encoded(url, "/v1/videos/") + + +class TestOpenAIContainersEncoding: + def _config(self): + from litellm.llms.openai.containers.transformation import ( + OpenAIContainerConfig, + ) + from litellm.types.router import GenericLiteLLMParams + + return OpenAIContainerConfig(), GenericLiteLLMParams() + + def test_retrieve(self): + cfg, params = self._config() + url, _ = cfg.transform_container_retrieve_request( + container_id=SAMPLE_INPUT, + api_base="https://api.openai.com/v1/containers", + litellm_params=params, + headers={}, + ) + _assert_input_is_encoded(url, "/v1/containers/") + + def test_delete(self): + cfg, params = self._config() + url, _ = cfg.transform_container_delete_request( + container_id=SAMPLE_INPUT, + api_base="https://api.openai.com/v1/containers", + litellm_params=params, + headers={}, + ) + _assert_input_is_encoded(url, "/v1/containers/") + + def test_file_list(self): + cfg, params = self._config() + url, _ = cfg.transform_container_file_list_request( + container_id=SAMPLE_INPUT, + api_base="https://api.openai.com/v1/containers", + litellm_params=params, + headers={}, + ) + _assert_input_is_encoded(url, "/v1/containers/") + + def test_file_content(self): + cfg, params = self._config() + url, _ = cfg.transform_container_file_content_request( + container_id="cntr_ok", + file_id=SAMPLE_INPUT, + api_base="https://api.openai.com/v1/containers", + litellm_params=params, + headers={}, + ) + _assert_input_is_encoded(url, "/cntr_ok/files/") + + +class TestOpenAIVectorStoresEncoding: + def test_search(self): + from litellm.llms.openai.vector_stores.transformation import ( + OpenAIVectorStoreConfig, + ) + + url, _ = OpenAIVectorStoreConfig().transform_search_vector_store_request( + vector_store_id=SAMPLE_INPUT, + query="x", + vector_store_search_optional_params={}, + api_base="https://api.openai.com/v1/vector_stores", + litellm_logging_obj=None, + litellm_params={}, + ) + _assert_input_is_encoded(url, "/v1/vector_stores/") + + +class TestOpenAIVectorStoreFilesEncoding: + def _config(self): + from litellm.llms.openai.vector_store_files.transformation import ( + OpenAIVectorStoreFilesConfig, + ) + + return OpenAIVectorStoreFilesConfig() + + def test_get_complete_url(self): + url = self._config().get_complete_url( + api_base="https://api.openai.com/v1", + vector_store_id=SAMPLE_INPUT, + litellm_params={}, + ) + _assert_input_is_encoded(url, "/vector_stores/") + + def test_retrieve(self): + url, _ = self._config().transform_retrieve_vector_store_file_request( + vector_store_id="vs_ok", + file_id=SAMPLE_INPUT, + api_base="https://api.openai.com/v1/vector_stores/vs_ok/files", + ) + _assert_input_is_encoded(url, "/files/") + + def test_content(self): + url, _ = self._config().transform_retrieve_vector_store_file_content_request( + vector_store_id="vs_ok", + file_id=SAMPLE_INPUT, + api_base="https://api.openai.com/v1/vector_stores/vs_ok/files", + ) + _assert_input_is_encoded(url, "/files/") + + def test_update(self): + url, _ = self._config().transform_update_vector_store_file_request( + vector_store_id="vs_ok", + file_id=SAMPLE_INPUT, + update_request={"attributes": None}, + api_base="https://api.openai.com/v1/vector_stores/vs_ok/files", + ) + _assert_input_is_encoded(url, "/files/") + + def test_delete(self): + url, _ = self._config().transform_delete_vector_store_file_request( + vector_store_id="vs_ok", + file_id=SAMPLE_INPUT, + api_base="https://api.openai.com/v1/vector_stores/vs_ok/files", + ) + _assert_input_is_encoded(url, "/files/") + + +class TestGeminiInteractionsEncoding: + def _config(self): + from litellm.llms.gemini.interactions.transformation import ( + GoogleAIStudioInteractionsConfig, + ) + from litellm.types.router import GenericLiteLLMParams + + return ( + GoogleAIStudioInteractionsConfig(), + GenericLiteLLMParams(api_key="sk-test"), + ) + + def test_get(self): + cfg, params = self._config() + url, _ = cfg.transform_get_interaction_request( + interaction_id=SAMPLE_INPUT, + api_base="https://generativelanguage.googleapis.com", + litellm_params=params, + headers={}, + ) + _assert_input_is_encoded(url, "/interactions/") + + def test_delete(self): + cfg, params = self._config() + url, _ = cfg.transform_delete_interaction_request( + interaction_id=SAMPLE_INPUT, + api_base="https://generativelanguage.googleapis.com", + litellm_params=params, + headers={}, + ) + _assert_input_is_encoded(url, "/interactions/") + + def test_cancel(self): + cfg, params = self._config() + url, _ = cfg.transform_cancel_interaction_request( + interaction_id=SAMPLE_INPUT, + api_base="https://generativelanguage.googleapis.com", + litellm_params=params, + headers={}, + ) + _assert_input_is_encoded(url, "/interactions/") + + +class TestBedrockCountTokensEncoding: + def _config(self): + from litellm.llms.bedrock.count_tokens.transformation import ( + BedrockCountTokensConfig, + ) + + return BedrockCountTokensConfig() + + def test_endpoint(self): + url = self._config().get_bedrock_count_tokens_endpoint( + model=SAMPLE_INPUT, + aws_region_name="us-east-1", + api_base=None, + aws_bedrock_runtime_endpoint=None, + ) + _assert_input_is_encoded(url, "/model/") + + def test_versioned_model_id_preserves_colon(self): + url = self._config().get_bedrock_count_tokens_endpoint( + model="amazon.nova-pro-v1:0", + aws_region_name="us-east-1", + api_base=None, + aws_bedrock_runtime_endpoint=None, + ) + assert "/model/amazon.nova-pro-v1:0/count-tokens" in url + + +class TestBedrockInvokeOpenAIEncoding: + def _config(self): + from litellm.llms.bedrock.chat.invoke_transformations.amazon_openai_transformation import ( + AmazonBedrockOpenAIConfig, + ) + + return AmazonBedrockOpenAIConfig() + + def test_versioned_model_id_preserves_colon(self): + cfg = self._config() + url = cfg.get_complete_url( + api_base=None, + api_key=None, + model="bedrock/openai/amazon.nova-pro-v1:0", + optional_params={"aws_region_name": "us-east-1"}, + litellm_params={}, + stream=False, + ) + assert "/model/amazon.nova-pro-v1:0/invoke" in url + + def test_traversal_input_is_encoded(self): + cfg = self._config() + url = cfg.get_complete_url( + api_base=None, + api_key=None, + model="bedrock/openai/../../foo", + optional_params={"aws_region_name": "us-east-1"}, + litellm_params={}, + stream=False, + ) + assert "..%2F..%2Ffoo" in url + assert "/model/../" not in url + + +class TestCloudflareEncoding: + def test_rejects_dot_segment(self, monkeypatch): + from litellm.llms.cloudflare.chat.transformation import CloudflareChatConfig + + monkeypatch.setenv("CLOUDFLARE_ACCOUNT_ID", "acct123") + cfg = CloudflareChatConfig() + with pytest.raises(ValueError): + cfg.get_complete_url( + api_base=None, + api_key="x", + model=SAMPLE_INPUT, + optional_params={}, + litellm_params={}, + stream=False, + ) + + def test_legitimate_model_preserved(self, monkeypatch): + from litellm.llms.cloudflare.chat.transformation import CloudflareChatConfig + + monkeypatch.setenv("CLOUDFLARE_ACCOUNT_ID", "acct123") + cfg = CloudflareChatConfig() + url = cfg.get_complete_url( + api_base=None, + api_key="x", + model="@cf/meta/llama-3.1-8b-instruct", + optional_params={}, + litellm_params={}, + stream=False, + ) + assert url.endswith("@cf/meta/llama-3.1-8b-instruct") + + +class TestBytezEncoding: + def test_rejects_dot_segment(self): + from litellm.llms.bytez.chat.transformation import BytezChatConfig + + with pytest.raises(ValueError): + BytezChatConfig().get_complete_url( + api_base=None, + api_key="x", + model=SAMPLE_INPUT, + optional_params={}, + litellm_params={}, + stream=False, + ) + + def test_legitimate_model_preserved(self): + from litellm.llms.bytez.chat.transformation import BytezChatConfig + + url = BytezChatConfig().get_complete_url( + api_base=None, + api_key="x", + model="google/gemma-3-4b-it", + optional_params={}, + litellm_params={}, + stream=False, + ) + assert url.endswith("google/gemma-3-4b-it") + + +class TestRagflowEncoding: + def test_rejects_dot_segment(self): + from litellm.llms.ragflow.chat.transformation import RAGFlowConfig + + cfg = RAGFlowConfig() + with pytest.raises(ValueError): + cfg.get_complete_url( + api_base="http://ragflow.example", + api_key="x", + model="ragflow/chat/../../v1/messages/batches/llama", + optional_params={}, + litellm_params={}, + stream=False, + ) + + def test_query_chars_in_entity_id_are_encoded(self): + from litellm.llms.ragflow.chat.transformation import RAGFlowConfig + + cfg = RAGFlowConfig() + url = cfg.get_complete_url( + api_base="http://ragflow.example", + api_key="x", + model="ragflow/chat/id?admin=1/llama", + optional_params={}, + litellm_params={}, + stream=False, + ) + assert "id%3Fadmin%3D1" in url + assert "?admin=1" not in url + + +class TestPGVectorEncoding: + def test_search(self): + from litellm.llms.pg_vector.vector_stores.transformation import ( + PGVectorStoreConfig, + ) + + url, _ = PGVectorStoreConfig().transform_search_vector_store_request( + vector_store_id=SAMPLE_INPUT, + query="x", + vector_store_search_optional_params={}, + api_base="http://pg.example/v1/vector_stores", + litellm_logging_obj=None, + litellm_params={}, + ) + _assert_input_is_encoded(url, "/v1/vector_stores/") + + +class TestContainerHandlerBuildUrl: + def test_path_params_encoded(self): + from litellm.llms.custom_httpx.container_handler import _build_url + + url = _build_url( + api_base="https://api.openai.com/v1/containers", + path_template="/containers/{container_id}/files/{file_id}", + path_params={"container_id": "cntr_ok", "file_id": SAMPLE_INPUT}, + ) + _assert_input_is_encoded(url, "/files/") + + +class TestOpenAIEvalsEncoding: + def _config(self): + from litellm.llms.openai.evals.transformation import OpenAIEvalsConfig + + return OpenAIEvalsConfig() + + def test_get_complete_url(self): + cfg = self._config() + url = cfg.get_complete_url( + api_base="https://api.openai.com", + endpoint="evals", + eval_id=SAMPLE_INPUT, + ) + _assert_input_is_encoded(url, "/v1/evals/") + + def test_get_run(self): + from litellm.types.router import GenericLiteLLMParams + + url, _ = self._config().transform_get_run_request( + eval_id="eval_ok", + run_id=SAMPLE_INPUT, + api_base="https://api.openai.com", + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + _assert_input_is_encoded(url, "/runs/") + + +class TestOpenAIResponsesEncoding: + def _config(self): + from litellm.llms.openai.responses.transformation import ( + OpenAIResponsesAPIConfig, + ) + from litellm.types.router import GenericLiteLLMParams + + return OpenAIResponsesAPIConfig(), GenericLiteLLMParams() + + def test_delete(self): + cfg, params = self._config() + url, _ = cfg.transform_delete_response_api_request( + response_id=SAMPLE_INPUT, + api_base="https://api.openai.com/v1/responses", + litellm_params=params, + headers={}, + ) + _assert_input_is_encoded(url, "/v1/responses/") + + +class TestManusFilesEncoding: + def _config(self): + from litellm.llms.manus.files.transformation import ManusFilesConfig + + return ManusFilesConfig() + + def test_retrieve(self): + url, _ = self._config().transform_retrieve_file_request( + file_id=SAMPLE_INPUT, + optional_params={}, + litellm_params={"api_base": "https://api.manus.im/v1/files"}, + ) + _assert_input_is_encoded(url, "/files/") + + +class TestAzureAIAgentsEncoding: + def _handler(self): + from litellm.llms.azure_ai.agents.handler import AzureAIAgentsHandler + + return AzureAIAgentsHandler.__new__(AzureAIAgentsHandler) + + def test_messages_url(self): + handler = self._handler() + url = handler._build_messages_url( + api_base="https://ai.example/api/projects/p", + thread_id=SAMPLE_INPUT, + api_version="2025-05-01", + ) + _assert_input_is_encoded(url, "/threads/") + + def test_run_status_url(self): + handler = self._handler() + url = handler._build_run_status_url( + api_base="https://ai.example/api/projects/p", + thread_id="thread_ok", + run_id=SAMPLE_INPUT, + api_version="2025-05-01", + ) + _assert_input_is_encoded(url, "/runs/") + + +class TestVertexVideosEncoding: + def test_operation_status_rejects_dot_segments(self): + from litellm.llms.vertex_ai.videos.transformation import ( + VertexAIVideoConfig, + ) + from litellm.types.router import GenericLiteLLMParams + + cfg = VertexAIVideoConfig() + with pytest.raises(ValueError): + cfg.transform_video_status_retrieve_request( + video_id="../../v1/models", + api_base="https://aiplatform.googleapis.com", + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + +class TestNvidiaNimEncoding: + def test_rejects_dot_segment(self): + from litellm.llms.nvidia_nim.rerank.transformation import ( + NvidiaNimRerankConfig, + ) + + cfg = NvidiaNimRerankConfig() + with pytest.raises(ValueError): + cfg.get_complete_url( + api_base="https://integrate.api.nvidia.com/v1", + model="nvidia_nim/../../v1/models", + optional_params={}, + ) + + def test_legitimate_model_preserved(self): + from litellm.llms.nvidia_nim.rerank.transformation import ( + NvidiaNimRerankConfig, + ) + + url = NvidiaNimRerankConfig().get_complete_url( + api_base="https://integrate.api.nvidia.com", + model="nvidia_nim/nvidia/nv-rerankqa-mistral-4b-v3", + optional_params={}, + ) + assert url.endswith("nvidia/nv-rerankqa-mistral-4b-v3/reranking") + + +class TestHuggingfaceEmbeddingEncoding: + def test_ids_encoded(self): + assert encode_url_path("BAAI/bge-large-en-v1.5") == "BAAI/bge-large-en-v1.5" + + +class TestElevenLabsEncoding: + def test_voice_id_encoded(self): + from litellm.llms.elevenlabs.text_to_speech.transformation import ( + ElevenLabsTextToSpeechConfig, + ) + + cfg = ElevenLabsTextToSpeechConfig() + url = cfg.get_complete_url( + model="eleven_multilingual_v2", + api_base="https://api.elevenlabs.io", + litellm_params={ + cfg.ELEVENLABS_VOICE_ID_KEY: SAMPLE_INPUT, + }, + ) + assert SAMPLE_INPUT_ENCODED in url + assert "../" not in url.split("/text-to-speech/", 1)[1]