fix: encode upstream URL path identifiers

This commit is contained in:
user 2026-04-29 22:02:39 -07:00
parent d3891e6eae
commit d4dd865b1a
47 changed files with 699 additions and 65 deletions

View File

@ -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.

View File

@ -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,

View File

@ -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 = {

View File

@ -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,

View File

@ -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(

View File

@ -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(

View File

@ -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

View File

@ -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),

View File

@ -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,

View File

@ -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 [

View File

@ -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)

View File

@ -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="",

View File

@ -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",
{},
)

View File

@ -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,

View File

@ -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

View File

@ -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

View File

@ -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] = {}

View File

@ -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

View File

@ -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,

View File

@ -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),

View File

@ -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(

View File

@ -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,

View File

@ -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("/"):

View File

@ -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] = {}

View File

@ -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

View File

@ -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

View File

@ -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": ".."},
)

View File

@ -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

View File

@ -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"):

View File

@ -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):

View File

@ -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(

View File

@ -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."""

View File

@ -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"
)

View File

@ -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()

View File

@ -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(

View File

@ -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"
)

View File

@ -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 == {}

View File

@ -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 == {}

View File

@ -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

View File

@ -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,

View File

@ -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"

View File

@ -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 == {}

View File

@ -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.

View File

@ -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()

View File

@ -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()

View File

@ -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",
[

View File

@ -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",