diff --git a/litellm/litellm_core_utils/url_utils.py b/litellm/litellm_core_utils/url_utils.py index a65d0892aa..fa110c0d06 100644 --- a/litellm/litellm_core_utils/url_utils.py +++ b/litellm/litellm_core_utils/url_utils.py @@ -22,7 +22,7 @@ Admins can opt out via two ``litellm`` globals (wired from proxy config): import socket from ipaddress import ip_address, ip_network from typing import Any, List, Set, Tuple -from urllib.parse import urlparse, urlunparse +from urllib.parse import quote, urlparse, urlunparse import httpx @@ -46,6 +46,26 @@ class SSRFError(ValueError): pass +def encode_url_path_segment(value: Any, *, field_name: str = "path parameter") -> str: + """Percent-encode one user-controlled URL path segment. + + ``urllib.parse.quote(..., safe="")`` intentionally leaves RFC 3986 + unreserved characters such as ``.`` unescaped, so reject standalone dot + segments before they can be appended to an upstream URL and normalized by + the HTTP client. + """ + if value is None: + raise ValueError(f"{field_name} is required") + + value_str = str(value) + if value_str == "": + raise ValueError(f"{field_name} is required") + if value_str in {".", ".."}: + raise ValueError(f"{field_name} cannot be a dot path segment") + + return quote(value_str, safe="") + + def _is_blocked_ip(addr: str) -> bool: """Return True for any IP not safe to reach from a user-supplied URL. diff --git a/litellm/llms/anthropic/batches/transformation.py b/litellm/llms/anthropic/batches/transformation.py index 3f03c744ef..fd67a7fbaf 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.litellm_core_utils.url_utils import encode_url_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,8 @@ 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}" + encoded_batch_id = encode_url_path_segment(batch_id, field_name="batch_id") + return f"{api_base.rstrip('/')}/v1/messages/batches/{encoded_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 c56799f30c..56296df94a 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.litellm_core_utils.url_utils import encode_url_path_segment from litellm.llms.custom_httpx.http_handler import get_async_httpx_client from litellm.types.llms.openai import ( FileContentRequest, @@ -89,7 +90,10 @@ 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" + encoded_batch_id = encode_url_path_segment(batch_id, field_name="batch_id") + results_url = ( + f"{api_base.rstrip('/')}/v1/messages/batches/{encoded_batch_id}/results" + ) # Prepare headers headers = { diff --git a/litellm/llms/anthropic/files/transformation.py b/litellm/llms/anthropic/files/transformation.py index aeaab4e57b..ea9bf00f50 100644 --- a/litellm/llms/anthropic/files/transformation.py +++ b/litellm/llms/anthropic/files/transformation.py @@ -19,6 +19,7 @@ from typing import Any, Dict, List, Optional, Union, cast import httpx from openai.types.file_deleted import FileDeleted +from litellm.litellm_core_utils.url_utils import encode_url_path_segment from litellm.litellm_core_utils.prompt_templates.common_utils import extract_file_data from litellm.llms.base_llm.chat.transformation import BaseLLMException from litellm.llms.base_llm.files.transformation import ( @@ -185,7 +186,8 @@ 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}", {} + encoded_file_id = encode_url_path_segment(file_id, field_name="file_id") + return f"{api_base.rstrip('/')}/v1/files/{encoded_file_id}", {} def transform_retrieve_file_response( self, @@ -206,7 +208,8 @@ 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}", {} + encoded_file_id = encode_url_path_segment(file_id, field_name="file_id") + return f"{api_base.rstrip('/')}/v1/files/{encoded_file_id}", {} def transform_delete_file_response( self, @@ -268,7 +271,8 @@ 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", {} + encoded_file_id = encode_url_path_segment(file_id, field_name="file_id") + return f"{api_base.rstrip('/')}/v1/files/{encoded_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 a992d84d45..4ea768b02a 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.litellm_core_utils.url_utils import encode_url_path_segment from litellm.llms.base_llm.skills.transformation import ( BaseSkillsAPIConfig, LiteLLMLoggingObj, @@ -81,7 +82,8 @@ class AnthropicSkillsConfig(BaseSkillsAPIConfig): api_base = AnthropicModelInfo.get_api_base() if skill_id: - return f"{api_base}/v1/skills/{skill_id}" + encoded_skill_id = encode_url_path_segment(skill_id, field_name="skill_id") + return f"{api_base}/v1/skills/{encoded_skill_id}" return f"{api_base}/v1/{endpoint}" def transform_create_skill_request( diff --git a/litellm/llms/azure/responses/transformation.py b/litellm/llms/azure/responses/transformation.py index 76a6d485bc..ca9293325f 100644 --- a/litellm/llms/azure/responses/transformation.py +++ b/litellm/llms/azure/responses/transformation.py @@ -5,6 +5,7 @@ import httpx from openai.types.responses import ResponseReasoningItem from litellm._logging import verbose_logger +from litellm.litellm_core_utils.url_utils import encode_url_path_segment from litellm.llms.azure.common_utils import BaseAzureLLM from litellm.llms.openai.responses.transformation import OpenAIResponsesAPIConfig from litellm.types.llms.openai import * @@ -201,7 +202,10 @@ 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}" + encoded_response_id = encode_url_path_segment( + response_id, field_name="response_id" + ) + new_path = f"{path}/{encoded_response_id}" # Reconstruct the URL with all original components but with the modified path constructed_url = urlunparse( @@ -322,7 +326,10 @@ 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" + encoded_response_id = encode_url_path_segment( + response_id, field_name="response_id" + ) + new_path = f"{path}/{encoded_response_id}/cancel" # Reconstruct the URL with all original components but with the modified path cancel_url = urlunparse( diff --git a/litellm/llms/bedrock/count_tokens/transformation.py b/litellm/llms/bedrock/count_tokens/transformation.py index a37af13162..c967fd334b 100644 --- a/litellm/llms/bedrock/count_tokens/transformation.py +++ b/litellm/llms/bedrock/count_tokens/transformation.py @@ -201,13 +201,14 @@ class BedrockCountTokensConfig(BaseAWSLLM): # Remove bedrock/ prefix if present if model_id.startswith("bedrock/"): model_id = model_id[8:] # Remove "bedrock/" prefix + encoded_model_id = self.encode_model_id(model_id=model_id) base_url, _ = self.get_runtime_endpoint( api_base=api_base, aws_bedrock_runtime_endpoint=aws_bedrock_runtime_endpoint, aws_region_name=aws_region_name, ) - endpoint = f"{base_url}/model/{model_id}/count-tokens" + endpoint = f"{base_url}/model/{encoded_model_id}/count-tokens" return endpoint diff --git a/litellm/llms/bedrock/vector_stores/transformation.py b/litellm/llms/bedrock/vector_stores/transformation.py index f028503c6a..ec20d76102 100644 --- a/litellm/llms/bedrock/vector_stores/transformation.py +++ b/litellm/llms/bedrock/vector_stores/transformation.py @@ -5,6 +5,7 @@ from urllib.parse import urlparse import httpx from litellm._logging import verbose_logger +from litellm.litellm_core_utils.url_utils import encode_url_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 ( @@ -209,7 +210,10 @@ class BedrockVectorStoreConfig(BaseVectorStoreConfig, BaseAWSLLM): if isinstance(query, list): query = " ".join(query) - url = f"{api_base}/{vector_store_id}/retrieve" + encoded_vector_store_id = encode_url_path_segment( + vector_store_id, field_name="vector_store_id" + ) + url = f"{api_base}/{encoded_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 a72f732a30..e7364d97ce 100644 --- a/litellm/llms/bytez/chat/transformation.py +++ b/litellm/llms/bytez/chat/transformation.py @@ -5,6 +5,7 @@ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union import httpx +from litellm.litellm_core_utils.url_utils import encode_url_path_segment 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.chat.transformation import BaseConfig, BaseLLMException @@ -149,7 +150,8 @@ class BytezChatConfig(BaseConfig): litellm_params: dict, stream: Optional[bool] = None, ) -> str: - return f"{API_BASE}/{model}" + encoded_model = encode_url_path_segment(model, field_name="model") + return f"{API_BASE}/{encoded_model}" def transform_request( self, diff --git a/litellm/llms/cloudflare/chat/transformation.py b/litellm/llms/cloudflare/chat/transformation.py index 9e59782bf7..b207b49606 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.litellm_core_utils.url_utils import encode_url_path_segment from litellm.llms.base_llm.base_model_iterator import BaseModelResponseIterator from litellm.llms.base_llm.chat.transformation import ( BaseConfig, @@ -89,7 +90,8 @@ class CloudflareChatConfig(BaseConfig): api_base = ( f"https://api.cloudflare.com/client/v4/accounts/{account_id}/ai/run/" ) - return api_base + model + encoded_model = encode_url_path_segment(model, field_name="model") + return api_base + encoded_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 afdd7bc6a8..599cd705eb 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.litellm_core_utils.url_utils import encode_url_path_segment from litellm.llms.custom_httpx.http_handler import ( AsyncHTTPHandler, HTTPHandler, @@ -72,7 +73,8 @@ def _build_url( # Substitute path parameters for param, value in path_params.items(): - path_template = path_template.replace(f"{{{param}}}", value) + encoded_value = encode_url_path_segment(value, field_name=param) + path_template = path_template.replace(f"{{{param}}}", encoded_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 a34b73b531..4b35fadf27 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.litellm_core_utils.url_utils import encode_url_path_segment from litellm.llms.base_llm.anthropic_messages.transformation import ( BaseAnthropicMessagesConfig, ) @@ -8934,7 +8935,10 @@ class BaseLLMHTTPHandler: litellm_params=dict(litellm_params), ) - url = f"{api_base}/{vector_store_id}" + encoded_vector_store_id = encode_url_path_segment( + vector_store_id, field_name="vector_store_id" + ) + url = f"{api_base}/{encoded_vector_store_id}" logging_obj.pre_call( input="", @@ -9001,7 +9005,10 @@ class BaseLLMHTTPHandler: litellm_params=dict(litellm_params), ) - url = f"{api_base}/{vector_store_id}" + encoded_vector_store_id = encode_url_path_segment( + vector_store_id, field_name="vector_store_id" + ) + url = f"{api_base}/{encoded_vector_store_id}" logging_obj.pre_call( input="", @@ -9200,7 +9207,10 @@ class BaseLLMHTTPHandler: litellm_params=dict(litellm_params), ) - url = f"{api_base}/{vector_store_id}" + encoded_vector_store_id = encode_url_path_segment( + vector_store_id, field_name="vector_store_id" + ) + url = f"{api_base}/{encoded_vector_store_id}" request_body: Dict[str, Any] = dict(vector_store_update_optional_params) @@ -9283,7 +9293,10 @@ class BaseLLMHTTPHandler: litellm_params=dict(litellm_params), ) - url = f"{api_base}/{vector_store_id}" + encoded_vector_store_id = encode_url_path_segment( + vector_store_id, field_name="vector_store_id" + ) + url = f"{api_base}/{encoded_vector_store_id}" request_body: Dict[str, Any] = dict(vector_store_update_optional_params) @@ -9349,7 +9362,10 @@ class BaseLLMHTTPHandler: litellm_params=dict(litellm_params), ) - url = f"{api_base}/{vector_store_id}" + encoded_vector_store_id = encode_url_path_segment( + vector_store_id, field_name="vector_store_id" + ) + url = f"{api_base}/{encoded_vector_store_id}" logging_obj.pre_call( input="", @@ -9414,7 +9430,10 @@ class BaseLLMHTTPHandler: litellm_params=dict(litellm_params), ) - url = f"{api_base}/{vector_store_id}" + encoded_vector_store_id = encode_url_path_segment( + vector_store_id, field_name="vector_store_id" + ) + url = f"{api_base}/{encoded_vector_store_id}" logging_obj.pre_call( input="", diff --git a/litellm/llms/gemini/interactions/transformation.py b/litellm/llms/gemini/interactions/transformation.py index c34da83cb8..593cbf7c2c 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.litellm_core_utils.url_utils import encode_url_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 ( @@ -205,8 +206,11 @@ class GoogleAIStudioInteractionsConfig(BaseInteractionsAPIConfig): resolved_api_base = GeminiModelInfo.get_api_base(api_base) if not GeminiModelInfo.get_api_key(litellm_params.api_key): raise ValueError("Google API key is required") + encoded_interaction_id = encode_url_path_segment( + interaction_id, field_name="interaction_id" + ) return ( - f"{resolved_api_base}/{self.api_version}/interactions/{interaction_id}", + f"{resolved_api_base}/{self.api_version}/interactions/{encoded_interaction_id}", {}, ) @@ -238,8 +242,11 @@ class GoogleAIStudioInteractionsConfig(BaseInteractionsAPIConfig): resolved_api_base = GeminiModelInfo.get_api_base(api_base) if not GeminiModelInfo.get_api_key(litellm_params.api_key): raise ValueError("Google API key is required") + encoded_interaction_id = encode_url_path_segment( + interaction_id, field_name="interaction_id" + ) return ( - f"{resolved_api_base}/{self.api_version}/interactions/{interaction_id}", + f"{resolved_api_base}/{self.api_version}/interactions/{encoded_interaction_id}", {}, ) @@ -268,8 +275,11 @@ class GoogleAIStudioInteractionsConfig(BaseInteractionsAPIConfig): resolved_api_base = GeminiModelInfo.get_api_base(api_base) if not GeminiModelInfo.get_api_key(litellm_params.api_key): raise ValueError("Google API key is required") + encoded_interaction_id = encode_url_path_segment( + interaction_id, field_name="interaction_id" + ) return ( - f"{resolved_api_base}/{self.api_version}/interactions/{interaction_id}:cancel", + f"{resolved_api_base}/{self.api_version}/interactions/{encoded_interaction_id}:cancel", {}, ) diff --git a/litellm/llms/manus/files/transformation.py b/litellm/llms/manus/files/transformation.py index 3381a5327e..3416616139 100644 --- a/litellm/llms/manus/files/transformation.py +++ b/litellm/llms/manus/files/transformation.py @@ -18,6 +18,7 @@ from openai.types.file_deleted import FileDeleted import litellm from litellm._logging import verbose_logger +from litellm.litellm_core_utils.url_utils import encode_url_path_segment from litellm.litellm_core_utils.prompt_templates.common_utils import extract_file_data from litellm.llms.base_llm.chat.transformation import BaseLLMException from litellm.llms.base_llm.files.transformation import ( @@ -306,7 +307,8 @@ class ManusFilesConfig(BaseFilesConfig): optional_params=optional_params, litellm_params=litellm_params, ) - return f"{api_base}/{file_id}", {} + encoded_file_id = encode_url_path_segment(file_id, field_name="file_id") + return f"{api_base}/{encoded_file_id}", {} def transform_retrieve_file_response( self, @@ -336,7 +338,8 @@ class ManusFilesConfig(BaseFilesConfig): optional_params=optional_params, litellm_params=litellm_params, ) - return f"{api_base}/{file_id}", {} + encoded_file_id = encode_url_path_segment(file_id, field_name="file_id") + return f"{api_base}/{encoded_file_id}", {} def transform_delete_file_response( self, @@ -422,7 +425,8 @@ class ManusFilesConfig(BaseFilesConfig): optional_params=optional_params, litellm_params=litellm_params, ) - return f"{api_base}/{file_id}/content", {} + encoded_file_id = encode_url_path_segment(file_id, field_name="file_id") + return f"{api_base}/{encoded_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 510c41304a..b3a0073a5c 100644 --- a/litellm/llms/manus/responses/transformation.py +++ b/litellm/llms/manus/responses/transformation.py @@ -6,6 +6,7 @@ import httpx import litellm from litellm._logging import verbose_logger from litellm.litellm_core_utils.core_helpers import process_response_headers +from litellm.litellm_core_utils.url_utils import encode_url_path_segment from litellm.litellm_core_utils.llm_response_utils.convert_dict_to_response import ( _safe_convert_created_field, ) @@ -270,7 +271,10 @@ class ManusResponsesAPIConfig(OpenAIResponsesAPIConfig): Reference: https://open.manus.im/docs/openai-compatibility """ - url = f"{api_base}/{response_id}" + encoded_response_id = encode_url_path_segment( + response_id, field_name="response_id" + ) + url = f"{api_base}/{encoded_response_id}" data: Dict = {} return url, data diff --git a/litellm/llms/openai/containers/transformation.py b/litellm/llms/openai/containers/transformation.py index 955b9f760d..7f874ffd3b 100644 --- a/litellm/llms/openai/containers/transformation.py +++ b/litellm/llms/openai/containers/transformation.py @@ -6,6 +6,7 @@ import litellm from litellm.litellm_core_utils.llm_cost_calc.tool_call_cost_tracking import ( StandardBuiltInToolCostTracking, ) +from litellm.litellm_core_utils.url_utils import encode_url_path_segment from litellm.secret_managers.main import get_secret_str from litellm.types.containers.main import ( ContainerCreateOptionalRequestParams, @@ -198,7 +199,10 @@ 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}") + encoded_container_id = encode_url_path_segment( + container_id, field_name="container_id" + ) + url = join_container_api_base_path(api_base, f"/{encoded_container_id}") # No additional data needed for GET request data: Dict[str, Any] = {} @@ -230,7 +234,10 @@ 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}") + encoded_container_id = encode_url_path_segment( + container_id, field_name="container_id" + ) + url = join_container_api_base_path(api_base, f"/{encoded_container_id}") # No data needed for DELETE request data: Dict[str, Any] = {} @@ -267,7 +274,10 @@ 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") + encoded_container_id = encode_url_path_segment( + container_id, field_name="container_id" + ) + url = join_container_api_base_path(api_base, f"/{encoded_container_id}/files") # Prepare query parameters params: Dict[str, Any] = {} @@ -311,8 +321,12 @@ class OpenAIContainerConfig(BaseContainerConfig): - GET /v1/containers/{container_id}/files/{file_id}/content """ # Construct the URL for container file content + encoded_container_id = encode_url_path_segment( + container_id, field_name="container_id" + ) + encoded_file_id = encode_url_path_segment(file_id, field_name="file_id") url = join_container_api_base_path( - api_base, f"/{container_id}/files/{file_id}/content" + api_base, f"/{encoded_container_id}/files/{encoded_file_id}/content" ) # No query parameters needed diff --git a/litellm/llms/openai/evals/transformation.py b/litellm/llms/openai/evals/transformation.py index c24dbf8637..66537e56a6 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.litellm_core_utils.url_utils import encode_url_path_segment from litellm.llms.base_llm.evals.transformation import ( BaseEvalsAPIConfig, LiteLLMLoggingObj, @@ -76,7 +77,8 @@ class OpenAIEvalsConfig(BaseEvalsAPIConfig): api_base = "https://api.openai.com" if eval_id: - return f"{api_base}/v1/evals/{eval_id}" + encoded_eval_id = encode_url_path_segment(eval_id, field_name="eval_id") + return f"{api_base}/v1/evals/{encoded_eval_id}" return f"{api_base}/v1/{endpoint}" def transform_create_eval_request( @@ -276,7 +278,8 @@ 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" + encoded_eval_id = encode_url_path_segment(eval_id, field_name="eval_id") + url = f"{api_base}/v1/evals/{encoded_eval_id}/runs" # Build request body request_body = {k: v for k, v in create_request.items() if v is not None} @@ -310,7 +313,8 @@ 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" + encoded_eval_id = encode_url_path_segment(eval_id, field_name="eval_id") + url = f"{api_base}/v1/evals/{encoded_eval_id}/runs" # Build query parameters query_params: Dict[str, Any] = {} @@ -350,7 +354,9 @@ 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}" + encoded_eval_id = encode_url_path_segment(eval_id, field_name="eval_id") + encoded_run_id = encode_url_path_segment(run_id, field_name="run_id") + url = f"{api_base}/v1/evals/{encoded_eval_id}/runs/{encoded_run_id}" verbose_logger.debug("Get run request - URL: %s", url) @@ -376,7 +382,9 @@ 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" + encoded_eval_id = encode_url_path_segment(eval_id, field_name="eval_id") + encoded_run_id = encode_url_path_segment(run_id, field_name="run_id") + url = f"{api_base}/v1/evals/{encoded_eval_id}/runs/{encoded_run_id}/cancel" # Empty body for cancel request request_body: Dict[str, Any] = {} @@ -405,7 +413,9 @@ 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}" + encoded_eval_id = encode_url_path_segment(eval_id, field_name="eval_id") + encoded_run_id = encode_url_path_segment(run_id, field_name="run_id") + url = f"{api_base}/v1/evals/{encoded_eval_id}/runs/{encoded_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 87c502032c..b7d5340d8d 100644 --- a/litellm/llms/openai/responses/transformation.py +++ b/litellm/llms/openai/responses/transformation.py @@ -7,6 +7,7 @@ from pydantic import BaseModel, ValidationError import litellm from litellm._logging import verbose_logger from litellm.litellm_core_utils.core_helpers import process_response_headers +from litellm.litellm_core_utils.url_utils import encode_url_path_segment from litellm.litellm_core_utils.llm_response_utils.convert_dict_to_response import ( _safe_convert_created_field, ) @@ -421,7 +422,10 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig): OpenAI API expects the following request - DELETE /v1/responses/{response_id} """ - url = f"{api_base}/{response_id}" + encoded_response_id = encode_url_path_segment( + response_id, field_name="response_id" + ) + url = f"{api_base}/{encoded_response_id}" data: Dict = {} return url, data @@ -457,7 +461,10 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig): OpenAI API expects the following request - GET /v1/responses/{response_id} """ - url = f"{api_base}/{response_id}" + encoded_response_id = encode_url_path_segment( + response_id, field_name="response_id" + ) + url = f"{api_base}/{encoded_response_id}" data: Dict = {} return url, data @@ -498,7 +505,10 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig): limit: int = 20, order: Literal["asc", "desc"] = "desc", ) -> Tuple[str, Dict]: - url = f"{api_base}/{response_id}/input_items" + encoded_response_id = encode_url_path_segment( + response_id, field_name="response_id" + ) + url = f"{api_base}/{encoded_response_id}/input_items" params: Dict[str, Any] = {} if after is not None: params["after"] = after @@ -540,7 +550,10 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig): OpenAI API expects the following request - POST /v1/responses/{response_id}/cancel """ - url = f"{api_base}/{response_id}/cancel" + encoded_response_id = encode_url_path_segment( + response_id, field_name="response_id" + ) + url = f"{api_base}/{encoded_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 cd5f10251b..52202f57fd 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.litellm_core_utils.url_utils import encode_url_path_segment from litellm.llms.base_llm.vector_store_files.transformation import ( BaseVectorStoreFilesConfig, ) @@ -98,7 +99,10 @@ class OpenAIVectorStoreFilesConfig(BaseVectorStoreFilesConfig): or "https://api.openai.com/v1" ) base_url = base_url.rstrip("/") - return f"{base_url}/vector_stores/{vector_store_id}/files" + encoded_vector_store_id = encode_url_path_segment( + vector_store_id, field_name="vector_store_id" + ) + return f"{base_url}/vector_stores/{encoded_vector_store_id}/files" def transform_create_vector_store_file_request( self, @@ -163,7 +167,8 @@ class OpenAIVectorStoreFilesConfig(BaseVectorStoreFilesConfig): file_id: str, api_base: str, ) -> Tuple[str, Dict[str, Any]]: - return f"{api_base}/{file_id}", {} + encoded_file_id = encode_url_path_segment(file_id, field_name="file_id") + return f"{api_base}/{encoded_file_id}", {} def transform_retrieve_vector_store_file_response( self, @@ -186,7 +191,8 @@ class OpenAIVectorStoreFilesConfig(BaseVectorStoreFilesConfig): file_id: str, api_base: str, ) -> Tuple[str, Dict[str, Any]]: - return f"{api_base}/{file_id}/content", {} + encoded_file_id = encode_url_path_segment(file_id, field_name="file_id") + return f"{api_base}/{encoded_file_id}/content", {} def transform_retrieve_vector_store_file_content_response( self, @@ -218,7 +224,8 @@ class OpenAIVectorStoreFilesConfig(BaseVectorStoreFilesConfig): payload["attributes"] = filtered_attributes else: payload.pop("attributes", None) - return f"{api_base}/{file_id}", payload + encoded_file_id = encode_url_path_segment(file_id, field_name="file_id") + return f"{api_base}/{encoded_file_id}", payload def transform_update_vector_store_file_response( self, @@ -241,7 +248,8 @@ class OpenAIVectorStoreFilesConfig(BaseVectorStoreFilesConfig): file_id: str, api_base: str, ) -> Tuple[str, Dict[str, Any]]: - return f"{api_base}/{file_id}", {} + encoded_file_id = encode_url_path_segment(file_id, field_name="file_id") + return f"{api_base}/{encoded_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 2c11d13748..bd095a0a1b 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.litellm_core_utils.url_utils import encode_url_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 @@ -108,7 +109,10 @@ class OpenAIVectorStoreConfig(BaseVectorStoreConfig): litellm_params: dict, extra_body: Optional[Dict[str, Any]] = None, ) -> Tuple[str, Dict]: - url = f"{api_base}/{vector_store_id}/search" + encoded_vector_store_id = encode_url_path_segment( + vector_store_id, field_name="vector_store_id" + ) + url = f"{api_base}/{encoded_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 61baa56949..6f25e4addb 100644 --- a/litellm/llms/openai/videos/transformation.py +++ b/litellm/llms/openai/videos/transformation.py @@ -6,6 +6,7 @@ import httpx from httpx._types import RequestFiles import litellm +from litellm.litellm_core_utils.url_utils import encode_url_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 @@ -220,9 +221,12 @@ class OpenAIVideoConfig(BaseVideoConfig): - GET /v1/videos/{video_id}/content?variant=thumbnail """ original_video_id = extract_original_video_id(video_id) + encoded_video_id = encode_url_path_segment( + original_video_id, field_name="video_id" + ) # Construct the URL for video content download - url = f"{api_base.rstrip('/')}/{original_video_id}/content" + url = f"{api_base.rstrip('/')}/{encoded_video_id}/content" if variant is not None: url = f"{url}?variant={variant}" @@ -247,9 +251,12 @@ class OpenAIVideoConfig(BaseVideoConfig): - POST /v1/videos/{video_id}/remix """ original_video_id = extract_original_video_id(video_id) + encoded_video_id = encode_url_path_segment( + original_video_id, field_name="video_id" + ) # Construct the URL for video remix - url = f"{api_base.rstrip('/')}/{original_video_id}/remix" + url = f"{api_base.rstrip('/')}/{encoded_video_id}/remix" # Prepare the request data data = {"prompt": prompt} @@ -391,9 +398,12 @@ class OpenAIVideoConfig(BaseVideoConfig): - DELETE /v1/videos/{video_id} """ original_video_id = extract_original_video_id(video_id) + encoded_video_id = encode_url_path_segment( + original_video_id, field_name="video_id" + ) # Construct the URL for video delete - url = f"{api_base.rstrip('/')}/{original_video_id}" + url = f"{api_base.rstrip('/')}/{encoded_video_id}" # No data needed for DELETE request data: Dict[str, Any] = {} @@ -427,9 +437,12 @@ class OpenAIVideoConfig(BaseVideoConfig): """ # Extract the original video_id (remove provider encoding if present) original_video_id = extract_original_video_id(video_id) + encoded_video_id = encode_url_path_segment( + original_video_id, field_name="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('/')}/{encoded_video_id}" # No additional data needed for GET request data: Dict[str, Any] = {} @@ -494,7 +507,11 @@ class OpenAIVideoConfig(BaseVideoConfig): litellm_params: GenericLiteLLMParams, headers: dict, ) -> Tuple[str, Dict]: - url = f"{api_base.rstrip('/')}/characters/{character_id}" + original_character_id = extract_original_character_id(character_id) + encoded_character_id = encode_url_path_segment( + original_character_id, field_name="character_id" + ) + url = f"{api_base.rstrip('/')}/characters/{encoded_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 7b22edd867..fc4cfc7b08 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.litellm_core_utils.url_utils import encode_url_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 @@ -82,7 +83,10 @@ class PGVectorStoreConfig(OpenAIVectorStoreConfig): litellm_params: dict, extra_body: Optional[Dict[str, Any]] = None, ) -> Tuple[str, Dict]: - url = f"{api_base}/{vector_store_id}/search" + encoded_vector_store_id = encode_url_path_segment( + vector_store_id, field_name="vector_store_id" + ) + url = f"{api_base}/{encoded_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/ragflow/chat/transformation.py b/litellm/llms/ragflow/chat/transformation.py index d49a5fd370..990fc2b2e6 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.litellm_core_utils.url_utils import encode_url_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,10 +127,11 @@ class RAGFlowConfig(OpenAIConfig): api_base = api_base[:-3] # Remove /v1 # Construct the RAGFlow-specific path + encoded_entity_id = encode_url_path_segment(entity_id, field_name="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" + path = f"/api/v1/agents_openai/{encoded_entity_id}/chat/completions" # Ensure path starts with / if not path.startswith("/"): diff --git a/litellm/llms/runwayml/videos/transformation.py b/litellm/llms/runwayml/videos/transformation.py index 8377dea952..4f84816a2b 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.litellm_core_utils.url_utils import encode_url_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 ( @@ -334,9 +335,12 @@ class RunwayMLVideoConfig(BaseVideoConfig): We'll retrieve the task and extract the video URL. """ original_video_id = extract_original_video_id(video_id) + encoded_video_id = encode_url_path_segment( + original_video_id, field_name="video_id" + ) # Get task status to retrieve video URL - url = f"{api_base}/tasks/{original_video_id}" + url = f"{api_base}/tasks/{encoded_video_id}" params: Dict[str, Any] = {} @@ -495,9 +499,12 @@ class RunwayMLVideoConfig(BaseVideoConfig): RunwayML uses task cancellation. """ original_video_id = extract_original_video_id(video_id) + encoded_video_id = encode_url_path_segment( + original_video_id, field_name="video_id" + ) # Construct the URL for task cancellation - url = f"{api_base}/tasks/{original_video_id}/cancel" + url = f"{api_base}/tasks/{encoded_video_id}/cancel" data: Dict[str, Any] = {} @@ -533,9 +540,12 @@ class RunwayMLVideoConfig(BaseVideoConfig): RunwayML uses GET /v1/tasks/{task_id} to retrieve task status. """ original_video_id = extract_original_video_id(video_id) + encoded_video_id = encode_url_path_segment( + original_video_id, field_name="video_id" + ) # Construct the full URL for task status retrieval - url = f"{api_base}/tasks/{original_video_id}" + url = f"{api_base}/tasks/{encoded_video_id}" # Empty dict for GET request (no body) data: Dict[str, Any] = {} diff --git a/litellm/llms/volcengine/responses/transformation.py b/litellm/llms/volcengine/responses/transformation.py index f6dda4dd25..99e0a958ef 100644 --- a/litellm/llms/volcengine/responses/transformation.py +++ b/litellm/llms/volcengine/responses/transformation.py @@ -17,6 +17,7 @@ from pydantic import fields as pyd_fields import litellm from litellm._logging import verbose_logger from litellm.litellm_core_utils.core_helpers import process_response_headers +from litellm.litellm_core_utils.url_utils import encode_url_path_segment from litellm.litellm_core_utils.llm_response_utils.convert_dict_to_response import ( _safe_convert_created_field, ) @@ -300,7 +301,10 @@ class VolcEngineResponsesAPIConfig(OpenAIResponsesAPIConfig): litellm_params: GenericLiteLLMParams, headers: dict, ) -> Tuple[str, Dict]: - url = f"{api_base}/{response_id}" + encoded_response_id = encode_url_path_segment( + response_id, field_name="response_id" + ) + url = f"{api_base}/{encoded_response_id}" data: Dict = {} return url, data @@ -333,7 +337,10 @@ class VolcEngineResponsesAPIConfig(OpenAIResponsesAPIConfig): litellm_params: GenericLiteLLMParams, headers: dict, ) -> Tuple[str, Dict]: - url = f"{api_base}/{response_id}" + encoded_response_id = encode_url_path_segment( + response_id, field_name="response_id" + ) + url = f"{api_base}/{encoded_response_id}" data: Dict = {} return url, data @@ -372,7 +379,10 @@ class VolcEngineResponsesAPIConfig(OpenAIResponsesAPIConfig): limit: int = 20, order: Literal["asc", "desc"] = "desc", ) -> Tuple[str, Dict]: - url = f"{api_base}/{response_id}/input_items" + encoded_response_id = encode_url_path_segment( + response_id, field_name="response_id" + ) + url = f"{api_base}/{encoded_response_id}/input_items" params: Dict[str, Any] = {} if after is not None: params["after"] = after @@ -408,7 +418,10 @@ class VolcEngineResponsesAPIConfig(OpenAIResponsesAPIConfig): litellm_params: GenericLiteLLMParams, headers: dict, ) -> Tuple[str, Dict]: - url = f"{api_base}/{response_id}/cancel" + encoded_response_id = encode_url_path_segment( + response_id, field_name="response_id" + ) + url = f"{api_base}/{encoded_response_id}/cancel" data: Dict = {} return url, data diff --git a/tests/test_litellm/containers/test_azure_container_transformation.py b/tests/test_litellm/containers/test_azure_container_transformation.py index a46046b318..c8cd4571de 100644 --- a/tests/test_litellm/containers/test_azure_container_transformation.py +++ b/tests/test_litellm/containers/test_azure_container_transformation.py @@ -323,6 +323,29 @@ class TestAzureContainerConfig: assert url_fc == expected_fc assert url_fc.index("/content") < url_fc.index("?") + def test_transform_requests_encode_path_ids_before_query_string(self): + from litellm.types.router import GenericLiteLLMParams + + api_base = ( + "https://my-resource.openai.azure.com/openai/v1/containers" + "?api-version=v1" + ) + + url, _ = self.config.transform_container_file_content_request( + container_id="../../other", + file_id="file?download=1#frag", + api_base=api_base, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + expected_url = ( + "https://my-resource.openai.azure.com/openai/v1/containers/" + "..%2F..%2Fother/files/file%3Fdownload%3D1%23frag/content" + "?api-version=v1" + ) + assert url == expected_url + def test_provider_config_manager_returns_azure_config(self): from litellm.types.utils import LlmProviders from litellm.utils import ProviderConfigManager diff --git a/tests/test_litellm/containers/test_container_handler_url.py b/tests/test_litellm/containers/test_container_handler_url.py new file mode 100644 index 0000000000..1927580068 --- /dev/null +++ b/tests/test_litellm/containers/test_container_handler_url.py @@ -0,0 +1,28 @@ +import pytest + +from litellm.llms.custom_httpx.container_handler import _build_url + + +def test_build_url_encodes_path_params_and_preserves_query(): + url = _build_url( + api_base="https://example.com/v1/containers?api-version=v1", + path_template="/containers/{container_id}/files/{file_id}/content", + path_params={ + "container_id": "../../containers/other", + "file_id": "file?download=1#frag", + }, + ) + + assert ( + url + == "https://example.com/v1/containers/..%2F..%2Fcontainers%2Fother/files/file%3Fdownload%3D1%23frag/content?api-version=v1" + ) + + +def test_build_url_rejects_dot_segment_path_param(): + with pytest.raises(ValueError, match="container_id cannot be a dot path segment"): + _build_url( + api_base="https://example.com/v1/containers", + path_template="/containers/{container_id}", + path_params={"container_id": ".."}, + ) diff --git a/tests/test_litellm/containers/test_container_transformation.py b/tests/test_litellm/containers/test_container_transformation.py index 47b5b8dc56..555fe7773f 100644 --- a/tests/test_litellm/containers/test_container_transformation.py +++ b/tests/test_litellm/containers/test_container_transformation.py @@ -230,6 +230,23 @@ class TestOpenAIContainerTransformation: assert url == f"{api_base}/{container_id}" assert params == {} # No query params for retrieve + def test_transform_container_retrieve_request_encodes_path_traversal(self): + """Test container IDs are treated as a single upstream path segment.""" + api_base = "https://api.openai.com/v1/containers" + + url, params = self.config.transform_container_retrieve_request( + container_id="../../vector_stores?x=1#frag", + api_base=api_base, + litellm_params={}, + headers={}, + ) + + assert ( + url + == "https://api.openai.com/v1/containers/..%2F..%2Fvector_stores%3Fx%3D1%23frag" + ) + assert params == {} + def test_transform_container_retrieve_response(self): """Test container retrieve response transformation.""" # Mock HTTP response diff --git a/tests/test_litellm/interactions/test_gemini_interactions_transformation.py b/tests/test_litellm/interactions/test_gemini_interactions_transformation.py index 37dc491c26..758ff3ea38 100644 --- a/tests/test_litellm/interactions/test_gemini_interactions_transformation.py +++ b/tests/test_litellm/interactions/test_gemini_interactions_transformation.py @@ -147,6 +147,21 @@ class TestInteractionOperationUrls: assert "secret-key" not in url assert expected_suffix in url + def test_interaction_id_is_encoded_as_one_path_segment(self, config): + with patch(_PATCH_GET_API_KEY, return_value="secret-key"): + url, params = config.transform_cancel_interaction_request( + interaction_id="../../interactions/other?x=1#frag", + api_base="https://generativelanguage.googleapis.com", + litellm_params=GenericLiteLLMParams(api_key="secret-key"), + headers={}, + ) + + assert ( + url + == "https://generativelanguage.googleapis.com/v1beta/interactions/..%2F..%2Finteractions%2Fother%3Fx%3D1%23frag:cancel" + ) + assert params == {} + def test_get_interaction_raises_without_key(self, config): with patch(_PATCH_GET_API_KEY, return_value=None): with pytest.raises(ValueError, match="Google API key is required"): diff --git a/tests/test_litellm/litellm_core_utils/test_url_utils.py b/tests/test_litellm/litellm_core_utils/test_url_utils.py index 4579c20321..036b8e759c 100644 --- a/tests/test_litellm/litellm_core_utils/test_url_utils.py +++ b/tests/test_litellm/litellm_core_utils/test_url_utils.py @@ -4,7 +4,12 @@ import pytest import litellm from litellm.litellm_core_utils import url_utils -from litellm.litellm_core_utils.url_utils import SSRFError, _is_blocked_ip, validate_url +from litellm.litellm_core_utils.url_utils import ( + SSRFError, + _is_blocked_ip, + encode_url_path_segment, + validate_url, +) @pytest.fixture @@ -80,6 +85,18 @@ class TestIsBlockedIp: assert _is_blocked_ip("::ffff:168.63.129.16") is True +class TestEncodeUrlPathSegment: + def test_encodes_path_delimiters_and_query_markers(self): + encoded = encode_url_path_segment("../../v1/files?limit=1#frag") + + assert encoded == "..%2F..%2Fv1%2Ffiles%3Flimit%3D1%23frag" + + @pytest.mark.parametrize("value", ["", ".", "..", None]) + def test_rejects_empty_and_dot_segments(self, value): + with pytest.raises(ValueError): + encode_url_path_segment(value, field_name="resource_id") + + class TestValidateUrl: def test_blocks_loopback(self): with pytest.raises(SSRFError): diff --git a/tests/test_litellm/llms/anthropic/files/test_anthropic_files_transformation.py b/tests/test_litellm/llms/anthropic/files/test_anthropic_files_transformation.py index e9509be9e1..9fc4981510 100644 --- a/tests/test_litellm/llms/anthropic/files/test_anthropic_files_transformation.py +++ b/tests/test_litellm/llms/anthropic/files/test_anthropic_files_transformation.py @@ -180,6 +180,19 @@ class TestAnthropicFilesConfig: assert url == "https://custom.api.com/v1/files/file-abc123" assert params == {} + def test_transform_retrieve_file_request_encodes_path_traversal(self): + url, params = self.config.transform_retrieve_file_request( + file_id="../../v1/messages/batches?limit=1#frag", + optional_params={}, + litellm_params={}, + ) + + assert ( + url + == f"{ANTHROPIC_FILES_API_BASE}/v1/files/..%2F..%2Fv1%2Fmessages%2Fbatches%3Flimit%3D1%23frag" + ) + assert params == {} + def test_transform_retrieve_file_response(self): mock_response = Mock(spec=httpx.Response) mock_response.json.return_value = { @@ -296,6 +309,14 @@ class TestAnthropicFilesConfig: assert url == f"{ANTHROPIC_FILES_API_BASE}/v1/files/file-abc123/content" assert params == {} + def test_transform_file_content_request_rejects_dot_segment(self): + with pytest.raises(ValueError, match="file_id cannot be a dot path segment"): + self.config.transform_file_content_request( + file_content_request={"file_id": ".."}, + optional_params={}, + litellm_params={}, + ) + def test_transform_file_content_response(self): mock_response = Mock(spec=httpx.Response) result = self.config.transform_file_content_response( diff --git a/tests/test_litellm/llms/azure/response/test_azure_transformation.py b/tests/test_litellm/llms/azure/response/test_azure_transformation.py index 9519b7c8a5..a4bd14d69f 100644 --- a/tests/test_litellm/llms/azure/response/test_azure_transformation.py +++ b/tests/test_litellm/llms/azure/response/test_azure_transformation.py @@ -96,6 +96,28 @@ def test_get_complete_url(): assert result == expected +@pytest.mark.serial +def test_response_id_path_requests_encode_response_id(): + config = AzureOpenAIResponsesAPIConfig() + api_base = ( + "https://litellm8397336933.openai.azure.com/openai/responses" + "?api-version=2024-05-01-preview" + ) + + url, params = config.transform_cancel_response_api_request( + response_id="../../responses/other?x=1#frag", + api_base=api_base, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + assert ( + url + == "https://litellm8397336933.openai.azure.com/openai/responses/..%2F..%2Fresponses%2Fother%3Fx%3D1%23frag/cancel?api-version=2024-05-01-preview" + ) + assert params == {} + + @pytest.mark.serial def test_azure_o_series_responses_api_supported_params(): """Test that Azure OpenAI O-series responses API excludes temperature from supported parameters.""" diff --git a/tests/test_litellm/llms/bedrock/count_tokens/test_bedrock_count_tokens_transformation.py b/tests/test_litellm/llms/bedrock/count_tokens/test_bedrock_count_tokens_transformation.py index eac022ec23..0de3f833a3 100644 --- a/tests/test_litellm/llms/bedrock/count_tokens/test_bedrock_count_tokens_transformation.py +++ b/tests/test_litellm/llms/bedrock/count_tokens/test_bedrock_count_tokens_transformation.py @@ -167,3 +167,24 @@ def test_tool_name_sanitization(): ] # Should be sanitized: only [a-zA-Z0-9_] assert tool_name == "my_tool_" + + +def test_count_tokens_endpoint_encodes_model_id(monkeypatch): + """Test model IDs are treated as a single Bedrock path segment.""" + config = BedrockCountTokensConfig() + + monkeypatch.setattr( + config, + "get_runtime_endpoint", + lambda **kwargs: ("https://bedrock-runtime.us-east-1.amazonaws.com", None), + ) + + endpoint = config.get_bedrock_count_tokens_endpoint( + model="bedrock/../../model/other?x=1#frag", + aws_region_name="us-east-1", + ) + + assert ( + endpoint + == "https://bedrock-runtime.us-east-1.amazonaws.com/model/..%2F..%2Fmodel%2Fother%3Fx%3D1%23frag/count-tokens" + ) diff --git a/tests/test_litellm/llms/bedrock/vector_stores/test_bedrock_vector_store_transformation.py b/tests/test_litellm/llms/bedrock/vector_stores/test_bedrock_vector_store_transformation.py index d60d0487d0..7b04efa17d 100644 --- a/tests/test_litellm/llms/bedrock/vector_stores/test_bedrock_vector_store_transformation.py +++ b/tests/test_litellm/llms/bedrock/vector_stores/test_bedrock_vector_store_transformation.py @@ -28,6 +28,28 @@ def test_transform_search_request(): assert body["retrievalQuery"].get("text") == "hello" +def test_transform_search_request_encodes_vector_store_id(): + config = BedrockVectorStoreConfig() + mock_log = MagicMock() + mock_log.model_call_details = {} + + url, body = config.transform_search_vector_store_request( + vector_store_id="../../knowledgebases/other?x=1#frag", + query="hello", + vector_store_search_optional_params={}, + api_base="https://bedrock-agent-runtime.us-west-2.amazonaws.com/knowledgebases", + litellm_logging_obj=mock_log, + litellm_params={}, + extra_body=None, + ) + + assert ( + url + == "https://bedrock-agent-runtime.us-west-2.amazonaws.com/knowledgebases/..%2F..%2Fknowledgebases%2Fother%3Fx%3D1%23frag/retrieve" + ) + assert body["retrievalQuery"].get("text") == "hello" + + def test_transform_search_request_uses_only_retrieval_config_from_extra_body(): config = BedrockVectorStoreConfig() mock_log = MagicMock() diff --git a/tests/test_litellm/llms/bytez/chat/test_bytez_chat_transformation.py b/tests/test_litellm/llms/bytez/chat/test_bytez_chat_transformation.py index dd388fedc6..78bd876c5f 100644 --- a/tests/test_litellm/llms/bytez/chat/test_bytez_chat_transformation.py +++ b/tests/test_litellm/llms/bytez/chat/test_bytez_chat_transformation.py @@ -2,6 +2,7 @@ import os import sys import pytest import json +from urllib.parse import quote # Adds the parent directory to the system path sys.path.insert(0, os.path.abspath("../../../../..")) @@ -69,7 +70,7 @@ class TestBytezChatConfig: } # Mock the HTTP request - respx_mock.post(f"{API_BASE}/{TEST_MODEL_NAME}").respond( + respx_mock.post(f"{API_BASE}/{quote(TEST_MODEL_NAME, safe='')}").respond( json={ "error": None, "output": output, @@ -87,6 +88,19 @@ class TestBytezChatConfig: assert response.choices[0].message.content == output_content # type: ignore + def test_get_complete_url_encodes_model_path_segment(self): + config = BytezChatConfig() + + url = config.get_complete_url( + api_base=API_BASE, + api_key=TEST_API_KEY, + model="../../models/other?x=1#frag", + optional_params={}, + litellm_params={}, + ) + + assert url == f"{API_BASE}/..%2F..%2Fmodels%2Fother%3Fx%3D1%23frag" + def test_bytez_messages_adaptation(self): cases = [ dict( diff --git a/tests/test_litellm/llms/cloudflare/test_cloudflare_transformation.py b/tests/test_litellm/llms/cloudflare/test_cloudflare_transformation.py new file mode 100644 index 0000000000..79dc85cf44 --- /dev/null +++ b/tests/test_litellm/llms/cloudflare/test_cloudflare_transformation.py @@ -0,0 +1,18 @@ +from litellm.llms.cloudflare.chat.transformation import CloudflareChatConfig + + +def test_get_complete_url_encodes_model_path_segment(): + config = CloudflareChatConfig() + + url = config.get_complete_url( + api_base="https://api.cloudflare.com/client/v4/accounts/acct/ai/run/", + api_key="cf-key", + model="../../accounts/other?x=1#frag", + optional_params={}, + litellm_params={}, + ) + + assert ( + url + == "https://api.cloudflare.com/client/v4/accounts/acct/ai/run/..%2F..%2Faccounts%2Fother%3Fx%3D1%23frag" + ) diff --git a/tests/test_litellm/llms/manus/responses/test_manus_responses_transformation.py b/tests/test_litellm/llms/manus/responses/test_manus_responses_transformation.py index 10d66174c5..43ce030323 100644 --- a/tests/test_litellm/llms/manus/responses/test_manus_responses_transformation.py +++ b/tests/test_litellm/llms/manus/responses/test_manus_responses_transformation.py @@ -58,3 +58,18 @@ def test_transform_responses_api_request_adds_manus_params(): assert result["agent_profile"] == "manus-1.6" assert "input" in result assert "model" in result + + +def test_get_response_request_encodes_response_id(): + """Test response IDs are encoded before being appended to Manus URLs.""" + config = ManusResponsesAPIConfig() + + url, params = config.transform_get_response_api_request( + response_id="../../files?x=1#frag", + api_base="https://api.manus.im/v1/responses", + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + assert url == "https://api.manus.im/v1/responses/..%2F..%2Ffiles%3Fx%3D1%23frag" + assert params == {} diff --git a/tests/test_litellm/llms/openai/evals/test_openai_evals_transformation.py b/tests/test_litellm/llms/openai/evals/test_openai_evals_transformation.py index 751e48ff7a..f39be511b9 100644 --- a/tests/test_litellm/llms/openai/evals/test_openai_evals_transformation.py +++ b/tests/test_litellm/llms/openai/evals/test_openai_evals_transformation.py @@ -49,6 +49,17 @@ def test_get_complete_url_with_eval_id(config: OpenAIEvalsConfig): assert url == "https://api.openai.com/v1/evals/eval_123" +def test_get_complete_url_encodes_eval_id(config: OpenAIEvalsConfig): + """Test eval_id is treated as a single path segment.""" + url = config.get_complete_url( + api_base="https://api.openai.com", + endpoint="evals", + eval_id="../../files?x=1#frag", + ) + + assert url == "https://api.openai.com/v1/evals/..%2F..%2Ffiles%3Fx%3D1%23frag" + + def test_get_complete_url_without_eval_id(config: OpenAIEvalsConfig): """Test URL construction without eval_id""" url = config.get_complete_url( @@ -253,3 +264,20 @@ def test_transform_cancel_eval_response(config: OpenAIEvalsConfig): assert result.id == "eval_123" assert result.object == "eval" + + +def test_transform_run_requests_encode_eval_and_run_ids(config: OpenAIEvalsConfig): + """Test run path IDs are treated as single path segments.""" + url, _, request_body = config.transform_cancel_run_request( + eval_id="../../evals?x=1#frag", + run_id="../runs#other", + api_base="https://api.openai.com", + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + assert ( + url + == "https://api.openai.com/v1/evals/..%2F..%2Fevals%3Fx%3D1%23frag/runs/..%2Fruns%23other/cancel" + ) + assert request_body == {} diff --git a/tests/test_litellm/llms/openai/responses/test_openai_responses_transformation.py b/tests/test_litellm/llms/openai/responses/test_openai_responses_transformation.py index dae8784283..acb9fa9b64 100644 --- a/tests/test_litellm/llms/openai/responses/test_openai_responses_transformation.py +++ b/tests/test_litellm/llms/openai/responses/test_openai_responses_transformation.py @@ -265,6 +265,24 @@ class TestOpenAIResponsesAPIConfig: assert result == "https://custom-openai.example.com/v1/responses" + def test_response_id_path_requests_encode_response_id(self): + """Test response_id is treated as one upstream URL path segment.""" + api_base = "https://custom-openai.example.com/v1/responses" + response_id = "../../files?x=1#frag" + + url, data = self.config.transform_list_input_items_request( + response_id=response_id, + api_base=api_base, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + assert ( + url + == "https://custom-openai.example.com/v1/responses/..%2F..%2Ffiles%3Fx%3D1%23frag/input_items" + ) + assert data["limit"] == 20 + def test_get_event_model_class_generic_event(self): """Test that get_event_model_class returns the correct event model class""" from litellm.types.llms.openai import GenericEvent @@ -547,7 +565,12 @@ class TestOpenAIResponsesAPIConfig: """Base helper strips ``namespace`` from custom_tool_call for every provider path.""" inp = [ {"type": "function_call", "call_id": "a", "name": "f", "namespace": "keep"}, - {"type": "custom_tool_call", "call_id": "b", "name": "c", "namespace": "drop"}, + { + "type": "custom_tool_call", + "call_id": "b", + "name": "c", + "namespace": "drop", + }, ] out = BaseResponsesAPIConfig.strip_custom_tool_call_namespace_from_responses_input( inp diff --git a/tests/test_litellm/llms/openai/vector_store_files/test_openai_vector_store_files_transformation.py b/tests/test_litellm/llms/openai/vector_store_files/test_openai_vector_store_files_transformation.py index a8e07cd364..aebd93d062 100644 --- a/tests/test_litellm/llms/openai/vector_store_files/test_openai_vector_store_files_transformation.py +++ b/tests/test_litellm/llms/openai/vector_store_files/test_openai_vector_store_files_transformation.py @@ -32,6 +32,21 @@ def test_get_complete_url(config: OpenAIVectorStoreFilesConfig): assert url == "https://api.example.com/v1/vector_stores/vs_123/files" +def test_get_complete_url_encodes_vector_store_id( + config: OpenAIVectorStoreFilesConfig, +): + url = config.get_complete_url( + api_base="https://api.example.com/v1", + vector_store_id="../vs_123?x=1#frag", + litellm_params={}, + ) + + assert ( + url + == "https://api.example.com/v1/vector_stores/..%2Fvs_123%3Fx%3D1%23frag/files" + ) + + def test_transform_create_request(config: OpenAIVectorStoreFilesConfig): api_base = "https://api.example.com/v1/vector_stores/vs_123/files" url, payload = config.transform_create_vector_store_file_request( @@ -60,6 +75,22 @@ def test_transform_list_request(config: OpenAIVectorStoreFilesConfig): assert params == {"limit": 2, "order": "asc"} +def test_transform_file_request_encodes_file_id(config: OpenAIVectorStoreFilesConfig): + api_base = "https://api.example.com/v1/vector_stores/vs_123/files" + + url, params = config.transform_retrieve_vector_store_file_content_request( + vector_store_id="vs_123", + file_id="../../files?x=1#frag", + api_base=api_base, + ) + + assert ( + url + == "https://api.example.com/v1/vector_stores/vs_123/files/..%2F..%2Ffiles%3Fx%3D1%23frag/content" + ) + assert params == {} + + def test_transform_create_response(config: OpenAIVectorStoreFilesConfig): response = httpx.Response( status_code=200, diff --git a/tests/test_litellm/llms/openai/vector_stores/test_openai_vector_stores_transformation.py b/tests/test_litellm/llms/openai/vector_stores/test_openai_vector_stores_transformation.py index 053b107afb..e7b1aab45b 100644 --- a/tests/test_litellm/llms/openai/vector_stores/test_openai_vector_stores_transformation.py +++ b/tests/test_litellm/llms/openai/vector_stores/test_openai_vector_stores_transformation.py @@ -64,3 +64,21 @@ class TestOpenAIVectorStoreAPIConfig: for i in range(16): assert f"key_{i}" in request_body["metadata"] assert request_body["metadata"][f"key_{i}"] == f"value_{i}" + + def test_transform_search_vector_store_request_encodes_vector_store_id(self): + config = OpenAIVectorStoreConfig() + + url, request_body = config.transform_search_vector_store_request( + vector_store_id="../../files?x=1#frag", + query="hello", + vector_store_search_optional_params={}, + api_base="https://api.openai.com/v1/vector_stores", + litellm_logging_obj=None, # type: ignore[arg-type] + litellm_params={}, + ) + + assert ( + url + == "https://api.openai.com/v1/vector_stores/..%2F..%2Ffiles%3Fx%3D1%23frag/search" + ) + assert request_body["query"] == "hello" diff --git a/tests/test_litellm/llms/openai/videos/test_openai_video_transformation.py b/tests/test_litellm/llms/openai/videos/test_openai_video_transformation.py new file mode 100644 index 0000000000..0c7edfbb8d --- /dev/null +++ b/tests/test_litellm/llms/openai/videos/test_openai_video_transformation.py @@ -0,0 +1,42 @@ +from litellm.llms.openai.videos.transformation import OpenAIVideoConfig +from litellm.types.router import GenericLiteLLMParams +from litellm.types.videos.utils import encode_character_id_with_provider + + +def test_video_content_request_encodes_video_id_path_segment(): + config = OpenAIVideoConfig() + + url, params = config.transform_video_content_request( + video_id="../../responses?x=1#frag", + api_base="https://api.openai.com/v1/videos", + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + assert ( + url + == "https://api.openai.com/v1/videos/..%2F..%2Fresponses%3Fx%3D1%23frag/content" + ) + assert params == {} + + +def test_wrapped_character_id_is_decoded_then_encoded_as_path_segment(): + config = OpenAIVideoConfig() + character_id = encode_character_id_with_provider( + "../../characters?x=1#frag", + provider="openai", + model_id="sora", + ) + + url, params = config.transform_video_get_character_request( + character_id=character_id, + api_base="https://api.openai.com/v1/videos", + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + assert ( + url + == "https://api.openai.com/v1/videos/characters/..%2F..%2Fcharacters%3Fx%3D1%23frag" + ) + assert params == {} diff --git a/tests/test_litellm/llms/pg_vector/vector_stores/test_pg_vector_transformation.py b/tests/test_litellm/llms/pg_vector/vector_stores/test_pg_vector_transformation.py index e3343c3037..56953a574d 100644 --- a/tests/test_litellm/llms/pg_vector/vector_stores/test_pg_vector_transformation.py +++ b/tests/test_litellm/llms/pg_vector/vector_stores/test_pg_vector_transformation.py @@ -141,6 +141,24 @@ class TestPGVectorStoreConfig: assert headers["Authorization"] == "Bearer test_key" assert url == "https://example.com/v1/vector_stores" + def test_search_request_encodes_vector_store_id(self): + config = PGVectorStoreConfig() + + url, request_body = config.transform_search_vector_store_request( + vector_store_id="../../files?x=1#frag", + query="hello", + vector_store_search_optional_params={}, + api_base="https://example.com/v1/vector_stores", + litellm_logging_obj=Mock(), + litellm_params={}, + ) + + assert ( + url + == "https://example.com/v1/vector_stores/..%2F..%2Ffiles%3Fx%3D1%23frag/search" + ) + assert request_body["query"] == "hello" + def test_environment_variable_support(self): """ Test that environment variables are supported for configuration. diff --git a/tests/test_litellm/llms/ragflow/chat/test_ragflow_chat_transformation.py b/tests/test_litellm/llms/ragflow/chat/test_ragflow_chat_transformation.py index ae43eac7ff..baf2ab3391 100644 --- a/tests/test_litellm/llms/ragflow/chat/test_ragflow_chat_transformation.py +++ b/tests/test_litellm/llms/ragflow/chat/test_ragflow_chat_transformation.py @@ -117,6 +117,24 @@ class TestRAGFlowChatTransformation: == "http://localhost:9380/api/v1/agents_openai/my-agent-id/chat/completions" ) + def test_get_complete_url_encodes_entity_id(self): + """Test RAGFlow chat IDs are encoded as one upstream path segment.""" + config = RAGFlowConfig() + + url = config.get_complete_url( + api_base="http://localhost:9380", + api_key=None, + model="ragflow/chat/..%2F..%2Fagents_openai%2Fother/gpt-4o-mini", + optional_params={}, + litellm_params={}, + stream=False, + ) + + assert ( + url + == "http://localhost:9380/api/v1/chats_openai/..%252F..%252Fagents_openai%252Fother/chat/completions" + ) + def test_get_complete_url_strips_v1(self): """Test URL construction when api_base ends with /v1.""" config = RAGFlowConfig() diff --git a/tests/test_litellm/llms/runwayml/videos/test_runway_video_transformation.py b/tests/test_litellm/llms/runwayml/videos/test_runway_video_transformation.py index 755716c9da..24879ce83f 100644 --- a/tests/test_litellm/llms/runwayml/videos/test_runway_video_transformation.py +++ b/tests/test_litellm/llms/runwayml/videos/test_runway_video_transformation.py @@ -134,6 +134,21 @@ class TestRunwayMLVideoTransformation: with pytest.raises(ValueError, match="still processing"): self.config._extract_video_url_from_response(processing_response) + def test_transform_video_status_encodes_video_id_path_segment(self): + """Test task IDs are encoded before being appended to Runway URLs.""" + url, params = self.config.transform_video_status_retrieve_request( + video_id="../../tasks/other?x=1#frag", + api_base="https://api.dev.runwayml.com/v1", + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + assert ( + url + == "https://api.dev.runwayml.com/v1/tasks/..%2F..%2Ftasks%2Fother%3Fx%3D1%23frag" + ) + assert params == {} + def test_full_video_workflow(self): """Test complete video generation workflow from creation to status check.""" config = RunwayMLVideoConfig() diff --git a/tests/test_litellm/llms/volcengine/responses/test_volcengine_responses_transformation.py b/tests/test_litellm/llms/volcengine/responses/test_volcengine_responses_transformation.py index 623d162ccf..13571e63c7 100644 --- a/tests/test_litellm/llms/volcengine/responses/test_volcengine_responses_transformation.py +++ b/tests/test_litellm/llms/volcengine/responses/test_volcengine_responses_transformation.py @@ -101,6 +101,23 @@ class TestVolcengineResponsesAPITransformation: ) assert api_base_full == "https://custom.volc.com/api/v3/responses" + def test_response_id_path_requests_encode_response_id(self): + """response_id should be encoded before building Volcengine URLs.""" + config = VolcEngineResponsesAPIConfig() + + url, params = config.transform_cancel_response_api_request( + response_id="../../responses/other?x=1#frag", + api_base="https://custom.volc.com/api/v3/responses", + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + assert ( + url + == "https://custom.volc.com/api/v3/responses/..%2F..%2Fresponses%2Fother%3Fx%3D1%23frag/cancel" + ) + assert params == {} + @pytest.mark.parametrize( "litellm_params, expected_key", [ diff --git a/tests/test_litellm/test_anthropic_skills_transformation.py b/tests/test_litellm/test_anthropic_skills_transformation.py index 6761d67180..1b917f08ca 100644 --- a/tests/test_litellm/test_anthropic_skills_transformation.py +++ b/tests/test_litellm/test_anthropic_skills_transformation.py @@ -70,6 +70,15 @@ class TestAnthropicSkillsConfigURLConstruction: ) assert url == f"{FAKE_API_BASE}/v1/skills/skill_abc123" + def test_url_with_skill_id_encodes_path_segment(self): + url = self.config.get_complete_url( + api_base=FAKE_API_BASE, + endpoint="skills", + skill_id="../../files?x=1#frag", + ) + + assert url == f"{FAKE_API_BASE}/v1/skills/..%2F..%2Ffiles%3Fx%3D1%23frag" + def test_url_falls_back_to_anthropic_default(self): with patch( "litellm.llms.anthropic.common_utils.AnthropicModelInfo.get_api_base",