From 883db28407191511dd38923b9bbe8f461aeb225e Mon Sep 17 00:00:00 2001 From: Raushankumar0720 Date: Thu, 24 Sep 2026 11:12:28 +0530 Subject: [PATCH] fix(llms): sanitize trailing slashes in provider get_complete_url --- litellm/llms/deepseek/chat/transformation.py | 2 + litellm/llms/heroku/chat/transformation.py | 2 + .../llms/jina_ai/embedding/transformation.py | 7 +- .../llms/modelscope/chat/transformation.py | 2 + litellm/llms/moonshot/chat/transformation.py | 2 + litellm/llms/ollama/chat/transformation.py | 1 + .../llms/ollama/completion/transformation.py | 1 + .../perplexity/embedding/transformation.py | 1 + litellm/llms/snowflake/chat/transformation.py | 2 + .../snowflake/embedding/transformation.py | 2 + .../topaz/image_variations/transformation.py | 2 +- .../llms/voyage/embedding/transformation.py | 1 + .../embedding/transformation_contextual.py | 1 + .../embedding/transformation_multimodal.py | 1 + .../test_provider_url_trailing_slash.py | 126 ++++++++++++++++++ 15 files changed, 151 insertions(+), 2 deletions(-) create mode 100644 tests/llm_translation/test_provider_url_trailing_slash.py diff --git a/litellm/llms/deepseek/chat/transformation.py b/litellm/llms/deepseek/chat/transformation.py index ea19a7c7ddf..1e10ef491f1 100644 --- a/litellm/llms/deepseek/chat/transformation.py +++ b/litellm/llms/deepseek/chat/transformation.py @@ -366,6 +366,8 @@ class DeepSeekChatConfig(OpenAIGPTConfig): if not api_base: api_base = "https://api.deepseek.com/beta" + api_base = api_base.rstrip("/") + if not api_base.endswith("/chat/completions"): api_base = f"{api_base}/chat/completions" diff --git a/litellm/llms/heroku/chat/transformation.py b/litellm/llms/heroku/chat/transformation.py index fd0c29b080b..1aae85908f2 100644 --- a/litellm/llms/heroku/chat/transformation.py +++ b/litellm/llms/heroku/chat/transformation.py @@ -71,6 +71,8 @@ class HerokuChatConfig(OpenAIGPTConfig): "No api base was set. Please provide an api_base, or set the HEROKU_API_BASE environment variable." ) + api_base = api_base.rstrip("/") + if not api_base.endswith("/v1/chat/completions"): api_base = f"{api_base}/v1/chat/completions" diff --git a/litellm/llms/jina_ai/embedding/transformation.py b/litellm/llms/jina_ai/embedding/transformation.py index 260d9e6e494..a746d41562e 100644 --- a/litellm/llms/jina_ai/embedding/transformation.py +++ b/litellm/llms/jina_ai/embedding/transformation.py @@ -95,7 +95,12 @@ class JinaAIEmbeddingConfig(BaseEmbeddingConfig): litellm_params: dict, stream: bool | None = None, ) -> str: - return f"{api_base}/embeddings" if api_base else "https://api.jina.ai/v1/embeddings" + if api_base: + api_base = api_base.rstrip("/") + if not api_base.endswith("/embeddings"): + return f"{api_base}/embeddings" + return api_base + return "https://api.jina.ai/v1/embeddings" def transform_embedding_request( self, diff --git a/litellm/llms/modelscope/chat/transformation.py b/litellm/llms/modelscope/chat/transformation.py index d345b8efc56..56f1fe7acef 100644 --- a/litellm/llms/modelscope/chat/transformation.py +++ b/litellm/llms/modelscope/chat/transformation.py @@ -82,6 +82,8 @@ class ModelScopeChatConfig(OpenAIGPTConfig): if not api_base: api_base = self.DEFAULT_BASE_URL + api_base = api_base.rstrip("/") + if not api_base.endswith("/chat/completions"): api_base = f"{api_base}/chat/completions" diff --git a/litellm/llms/moonshot/chat/transformation.py b/litellm/llms/moonshot/chat/transformation.py index 7b0fcd24770..7f2c96e121b 100644 --- a/litellm/llms/moonshot/chat/transformation.py +++ b/litellm/llms/moonshot/chat/transformation.py @@ -89,6 +89,8 @@ class MoonshotChatConfig(OpenAIGPTConfig): if not api_base: api_base = "https://api.moonshot.ai/v1" + api_base = api_base.rstrip("/") + if not api_base.endswith("/chat/completions"): api_base = f"{api_base}/chat/completions" diff --git a/litellm/llms/ollama/chat/transformation.py b/litellm/llms/ollama/chat/transformation.py index cb3080e6534..851ed3cc5f3 100644 --- a/litellm/llms/ollama/chat/transformation.py +++ b/litellm/llms/ollama/chat/transformation.py @@ -220,6 +220,7 @@ class OllamaChatConfig(BaseConfig): """ if api_base is None: api_base = "http://localhost:11434" + api_base = api_base.rstrip("/") if api_base.endswith("/api/chat"): url = api_base else: diff --git a/litellm/llms/ollama/completion/transformation.py b/litellm/llms/ollama/completion/transformation.py index b1f69220de7..5c3c2be8fbf 100644 --- a/litellm/llms/ollama/completion/transformation.py +++ b/litellm/llms/ollama/completion/transformation.py @@ -470,6 +470,7 @@ class OllamaConfig(BaseConfig): """ if api_base is None: api_base = "http://localhost:11434" + api_base = api_base.rstrip("/") if api_base.endswith("/api/generate"): url = api_base else: diff --git a/litellm/llms/perplexity/embedding/transformation.py b/litellm/llms/perplexity/embedding/transformation.py index c93206db2bb..bc011978653 100644 --- a/litellm/llms/perplexity/embedding/transformation.py +++ b/litellm/llms/perplexity/embedding/transformation.py @@ -61,6 +61,7 @@ class PerplexityEmbeddingConfig(BaseEmbeddingConfig): stream: bool | None = None, ) -> str: if api_base: + api_base = api_base.rstrip("/") if not api_base.endswith("/embeddings"): api_base = f"{api_base}/v1/embeddings" return api_base diff --git a/litellm/llms/snowflake/chat/transformation.py b/litellm/llms/snowflake/chat/transformation.py index 734d0e20818..2a23795b429 100644 --- a/litellm/llms/snowflake/chat/transformation.py +++ b/litellm/llms/snowflake/chat/transformation.py @@ -236,6 +236,8 @@ class SnowflakeConfig(SnowflakeBaseConfig, OpenAIGPTConfig): stream: bool | None = None, ) -> str: api_base = self._get_api_base(api_base, optional_params) + if api_base: + api_base = api_base.rstrip("/") if _is_claude_model(model): return f"{api_base}/cortex/v1/messages" return f"{api_base}/cortex/v1/chat/completions" diff --git a/litellm/llms/snowflake/embedding/transformation.py b/litellm/llms/snowflake/embedding/transformation.py index 75f35a8f379..14b3f7c3e6d 100644 --- a/litellm/llms/snowflake/embedding/transformation.py +++ b/litellm/llms/snowflake/embedding/transformation.py @@ -26,6 +26,8 @@ class SnowflakeEmbeddingConfig(SnowflakeBaseConfig, BaseEmbeddingConfig): stream: bool | None = None, ) -> str: api_base = self._get_api_base(api_base, optional_params) + if api_base: + api_base = api_base.rstrip("/") return f"{api_base}/cortex/inference:embed" diff --git a/litellm/llms/topaz/image_variations/transformation.py b/litellm/llms/topaz/image_variations/transformation.py index 94f60d29cb9..441f751cc36 100644 --- a/litellm/llms/topaz/image_variations/transformation.py +++ b/litellm/llms/topaz/image_variations/transformation.py @@ -39,7 +39,7 @@ class TopazImageVariationConfig(TopazModelInfo, BaseImageVariationConfig): litellm_params: dict, stream: bool | None = None, ) -> str: - api_base = api_base or "https://api.topazlabs.com" + api_base = (api_base or "https://api.topazlabs.com").rstrip("/") return f"{api_base}/image/v1/enhance" def map_openai_params( diff --git a/litellm/llms/voyage/embedding/transformation.py b/litellm/llms/voyage/embedding/transformation.py index 7d74b1e00c4..e08ba9a9e7e 100644 --- a/litellm/llms/voyage/embedding/transformation.py +++ b/litellm/llms/voyage/embedding/transformation.py @@ -46,6 +46,7 @@ class VoyageEmbeddingConfig(BaseEmbeddingConfig): stream: bool | None = None, ) -> str: if api_base: + api_base = api_base.rstrip("/") if not api_base.endswith("/embeddings"): api_base = f"{api_base}/embeddings" return api_base diff --git a/litellm/llms/voyage/embedding/transformation_contextual.py b/litellm/llms/voyage/embedding/transformation_contextual.py index 870de8756bb..1a5001bd7c1 100644 --- a/litellm/llms/voyage/embedding/transformation_contextual.py +++ b/litellm/llms/voyage/embedding/transformation_contextual.py @@ -55,6 +55,7 @@ class VoyageContextualEmbeddingConfig(BaseEmbeddingConfig): stream: bool | None = None, ) -> str: if api_base: + api_base = api_base.rstrip("/") if not api_base.endswith("/contextualizedembeddings"): api_base = f"{api_base}/contextualizedembeddings" return api_base diff --git a/litellm/llms/voyage/embedding/transformation_multimodal.py b/litellm/llms/voyage/embedding/transformation_multimodal.py index 814d5ab7eb0..7bd9f2a5f68 100644 --- a/litellm/llms/voyage/embedding/transformation_multimodal.py +++ b/litellm/llms/voyage/embedding/transformation_multimodal.py @@ -55,6 +55,7 @@ class VoyageMultimodalEmbeddingConfig(BaseEmbeddingConfig): stream: bool | None = None, ) -> str: if api_base: + api_base = api_base.rstrip("/") if not api_base.endswith("/multimodalembeddings"): api_base = f"{api_base}/multimodalembeddings" return api_base diff --git a/tests/llm_translation/test_provider_url_trailing_slash.py b/tests/llm_translation/test_provider_url_trailing_slash.py new file mode 100644 index 00000000000..1864a94a28f --- /dev/null +++ b/tests/llm_translation/test_provider_url_trailing_slash.py @@ -0,0 +1,126 @@ +from litellm.llms.deepseek.chat.transformation import DeepSeekChatConfig +from litellm.llms.ollama.chat.transformation import OllamaChatConfig +from litellm.llms.ollama.completion.transformation import OllamaConfig +from litellm.llms.moonshot.chat.transformation import MoonshotChatConfig +from litellm.llms.modelscope.chat.transformation import ModelScopeChatConfig +from litellm.llms.jina_ai.embedding.transformation import JinaAIEmbeddingConfig +from litellm.llms.voyage.embedding.transformation import VoyageEmbeddingConfig +from litellm.llms.voyage.embedding.transformation_contextual import VoyageContextualEmbeddingConfig +from litellm.llms.voyage.embedding.transformation_multimodal import VoyageMultimodalEmbeddingConfig +from litellm.llms.perplexity.embedding.transformation import PerplexityEmbeddingConfig +from litellm.llms.snowflake.chat.transformation import SnowflakeConfig +from litellm.llms.snowflake.embedding.transformation import SnowflakeEmbeddingConfig +from litellm.llms.heroku.chat.transformation import HerokuChatConfig +from litellm.llms.topaz.image_variations.transformation import TopazImageVariationConfig + + +def test_deepseek_get_complete_url_trailing_slash(): + config = DeepSeekChatConfig() + url_without_slash = config.get_complete_url(api_base="https://custom.deepseek.com", api_key=None, model="deepseek-chat", optional_params={}, litellm_params={}) + url_with_slash = config.get_complete_url(api_base="https://custom.deepseek.com/", api_key=None, model="deepseek-chat", optional_params={}, litellm_params={}) + assert url_without_slash == "https://custom.deepseek.com/chat/completions" + assert url_with_slash == "https://custom.deepseek.com/chat/completions" + assert "//chat/completions" not in url_with_slash + + +def test_ollama_get_complete_url_trailing_slash(): + chat_config = OllamaChatConfig() + url_without_slash = chat_config.get_complete_url(api_base="http://localhost:11434", api_key=None, model="llama3", optional_params={}, litellm_params={}) + url_with_slash = chat_config.get_complete_url(api_base="http://localhost:11434/", api_key=None, model="llama3", optional_params={}, litellm_params={}) + assert url_without_slash == "http://localhost:11434/api/chat" + assert url_with_slash == "http://localhost:11434/api/chat" + assert "//api/chat" not in url_with_slash + + completion_config = OllamaConfig() + comp_url_without_slash = completion_config.get_complete_url(api_base="http://localhost:11434", api_key=None, model="llama3", optional_params={}, litellm_params={}) + comp_url_with_slash = completion_config.get_complete_url(api_base="http://localhost:11434/", api_key=None, model="llama3", optional_params={}, litellm_params={}) + assert comp_url_without_slash == "http://localhost:11434/api/generate" + assert comp_url_with_slash == "http://localhost:11434/api/generate" + assert "//api/generate" not in comp_url_with_slash + + +def test_moonshot_get_complete_url_trailing_slash(): + config = MoonshotChatConfig() + url_without_slash = config.get_complete_url(api_base="https://api.moonshot.ai/v1", api_key=None, model="moonshot-v1-8k", optional_params={}, litellm_params={}) + url_with_slash = config.get_complete_url(api_base="https://api.moonshot.ai/v1/", api_key=None, model="moonshot-v1-8k", optional_params={}, litellm_params={}) + assert url_without_slash == "https://api.moonshot.ai/v1/chat/completions" + assert url_with_slash == "https://api.moonshot.ai/v1/chat/completions" + assert "//chat/completions" not in url_with_slash + + +def test_modelscope_get_complete_url_trailing_slash(): + config = ModelScopeChatConfig() + url_without_slash = config.get_complete_url(api_base="https://api-inference.modelscope.cn/v1", api_key=None, model="qwen", optional_params={}, litellm_params={}) + url_with_slash = config.get_complete_url(api_base="https://api-inference.modelscope.cn/v1/", api_key=None, model="qwen", optional_params={}, litellm_params={}) + assert url_without_slash == "https://api-inference.modelscope.cn/v1/chat/completions" + assert url_with_slash == "https://api-inference.modelscope.cn/v1/chat/completions" + assert "//chat/completions" not in url_with_slash + + +def test_jina_ai_get_complete_url_trailing_slash(): + config = JinaAIEmbeddingConfig() + url_without_slash = config.get_complete_url(api_base="https://api.jina.ai/v1", api_key=None, model="jina-embeddings-v2", optional_params={}, litellm_params={}) + url_with_slash = config.get_complete_url(api_base="https://api.jina.ai/v1/", api_key=None, model="jina-embeddings-v2", optional_params={}, litellm_params={}) + assert url_without_slash == "https://api.jina.ai/v1/embeddings" + assert url_with_slash == "https://api.jina.ai/v1/embeddings" + assert "//embeddings" not in url_with_slash + + +def test_voyage_get_complete_url_trailing_slash(): + config = VoyageEmbeddingConfig() + url_without_slash = config.get_complete_url(api_base="https://api.voyageai.com/v1", api_key=None, model="voyage-large-2", optional_params={}, litellm_params={}) + url_with_slash = config.get_complete_url(api_base="https://api.voyageai.com/v1/", api_key=None, model="voyage-large-2", optional_params={}, litellm_params={}) + assert url_without_slash == "https://api.voyageai.com/v1/embeddings" + assert url_with_slash == "https://api.voyageai.com/v1/embeddings" + assert "//embeddings" not in url_with_slash + + contextual_config = VoyageContextualEmbeddingConfig() + c_url = contextual_config.get_complete_url(api_base="https://api.voyageai.com/v1/", api_key=None, model="voyage-context-2", optional_params={}, litellm_params={}) + assert c_url == "https://api.voyageai.com/v1/contextualizedembeddings" + assert "//contextualizedembeddings" not in c_url + + multimodal_config = VoyageMultimodalEmbeddingConfig() + m_url = multimodal_config.get_complete_url(api_base="https://api.voyageai.com/v1/", api_key=None, model="voyage-multimodal-3", optional_params={}, litellm_params={}) + assert m_url == "https://api.voyageai.com/v1/multimodalembeddings" + assert "//multimodalembeddings" not in m_url + + +def test_perplexity_get_complete_url_trailing_slash(): + config = PerplexityEmbeddingConfig() + url_without_slash = config.get_complete_url(api_base="https://api.perplexity.ai", api_key=None, model="sonar-medium", optional_params={}, litellm_params={}) + url_with_slash = config.get_complete_url(api_base="https://api.perplexity.ai/", api_key=None, model="sonar-medium", optional_params={}, litellm_params={}) + assert url_without_slash == "https://api.perplexity.ai/v1/embeddings" + assert url_with_slash == "https://api.perplexity.ai/v1/embeddings" + assert "//v1/embeddings" not in url_with_slash + + +def test_snowflake_get_complete_url_trailing_slash(): + config = SnowflakeConfig() + url_without_slash = config.get_complete_url(api_base="https://myorg-myaccount.snowflakecomputing.com", api_key=None, model="snowflake-arctic", optional_params={}, litellm_params={}) + url_with_slash = config.get_complete_url(api_base="https://myorg-myaccount.snowflakecomputing.com/", api_key=None, model="snowflake-arctic", optional_params={}, litellm_params={}) + assert url_without_slash == "https://myorg-myaccount.snowflakecomputing.com/api/v2/cortex/v1/chat/completions" + assert url_with_slash == "https://myorg-myaccount.snowflakecomputing.com/api/v2/cortex/v1/chat/completions" + assert "//cortex" not in url_with_slash + + embed_config = SnowflakeEmbeddingConfig() + embed_url = embed_config.get_complete_url(api_base="https://myorg-myaccount.snowflakecomputing.com/", api_key=None, model="snowflake-arctic-embed", optional_params={}, litellm_params={}) + assert embed_url == "https://myorg-myaccount.snowflakecomputing.com/api/v2/cortex/inference:embed" + assert "//cortex" not in embed_url + + +def test_heroku_get_complete_url_trailing_slash(): + config = HerokuChatConfig() + url_without_slash = config.get_complete_url(api_base="https://my-app.herokuapp.com", api_key=None, model="claude-3-5-sonnet", optional_params={}, litellm_params={}) + url_with_slash = config.get_complete_url(api_base="https://my-app.herokuapp.com/", api_key=None, model="claude-3-5-sonnet", optional_params={}, litellm_params={}) + assert url_without_slash == "https://my-app.herokuapp.com/v1/chat/completions" + assert url_with_slash == "https://my-app.herokuapp.com/v1/chat/completions" + assert "//v1/chat/completions" not in url_with_slash + + +def test_topaz_get_complete_url_trailing_slash(): + config = TopazImageVariationConfig() + url_without_slash = config.get_complete_url(api_base="https://api.topazlabs.com", api_key=None, model="topaz-v1", optional_params={}, litellm_params={}) + url_with_slash = config.get_complete_url(api_base="https://api.topazlabs.com/", api_key=None, model="topaz-v1", optional_params={}, litellm_params={}) + assert url_without_slash == "https://api.topazlabs.com/image/v1/enhance" + assert url_with_slash == "https://api.topazlabs.com/image/v1/enhance" + assert "//image/v1/enhance" not in url_with_slash