diff --git a/litellm/llms/deepseek/chat/transformation.py b/litellm/llms/deepseek/chat/transformation.py index 4e428a23392..7b68d6837f1 100644 --- a/litellm/llms/deepseek/chat/transformation.py +++ b/litellm/llms/deepseek/chat/transformation.py @@ -363,10 +363,9 @@ class DeepSeekChatConfig(OpenAIGPTConfig): """ If api_base is not provided, use the default DeepSeek /chat/completions endpoint. """ - if not api_base: - api_base = "https://api.deepseek.com/beta" + clean_api_base: Final = (api_base or "https://api.deepseek.com/beta").rstrip("/") - if not api_base.endswith("/chat/completions"): - api_base = f"{api_base}/chat/completions" + if not clean_api_base.endswith("/chat/completions"): + return f"{clean_api_base}/chat/completions" - return api_base + return clean_api_base diff --git a/litellm/llms/heroku/chat/transformation.py b/litellm/llms/heroku/chat/transformation.py index fd0c29b080b..546424a7e4c 100644 --- a/litellm/llms/heroku/chat/transformation.py +++ b/litellm/llms/heroku/chat/transformation.py @@ -6,7 +6,7 @@ this is OpenAI compatible - no translation needed / occurs import os from collections.abc import Coroutine -from typing import Any, Literal, overload +from typing import Any, Final, Literal, overload from litellm.litellm_core_utils.prompt_templates.common_utils import ( handle_messages_with_content_list_to_str_conversion, @@ -64,14 +64,16 @@ class HerokuChatConfig(OpenAIGPTConfig): litellm_params: dict, stream: bool | None = None, ) -> str: - api_base, _ = self._get_openai_compatible_provider_info(api_base, api_key) + resolved_base, _ = self._get_openai_compatible_provider_info(api_base, api_key) - if not api_base: + if not resolved_base: raise HerokuError( "No api base was set. Please provide an api_base, or set the HEROKU_API_BASE environment variable." ) - if not api_base.endswith("/v1/chat/completions"): - api_base = f"{api_base}/v1/chat/completions" + clean_api_base: Final = resolved_base.rstrip("/") - return api_base + if not clean_api_base.endswith("/v1/chat/completions"): + return f"{clean_api_base}/v1/chat/completions" + + return clean_api_base diff --git a/litellm/llms/jina_ai/embedding/transformation.py b/litellm/llms/jina_ai/embedding/transformation.py index 260d9e6e494..37d791701fc 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: + clean_api_base: Final = api_base.rstrip("/") + if not clean_api_base.endswith("/embeddings"): + return f"{clean_api_base}/embeddings" + return clean_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..2f4855f6513 100644 --- a/litellm/llms/modelscope/chat/transformation.py +++ b/litellm/llms/modelscope/chat/transformation.py @@ -79,10 +79,9 @@ class ModelScopeChatConfig(OpenAIGPTConfig): """ If api_base is not provided, use the default ModelScope /chat/completions endpoint. """ - if not api_base: - api_base = self.DEFAULT_BASE_URL + clean_api_base: Final = (api_base or self.DEFAULT_BASE_URL).rstrip("/") - if not api_base.endswith("/chat/completions"): - api_base = f"{api_base}/chat/completions" + if not clean_api_base.endswith("/chat/completions"): + return f"{clean_api_base}/chat/completions" - return api_base + return clean_api_base diff --git a/litellm/llms/moonshot/chat/transformation.py b/litellm/llms/moonshot/chat/transformation.py index 7b0fcd24770..a6b546ae4e2 100644 --- a/litellm/llms/moonshot/chat/transformation.py +++ b/litellm/llms/moonshot/chat/transformation.py @@ -86,13 +86,12 @@ class MoonshotChatConfig(OpenAIGPTConfig): """ If api_base is not provided, use the default Moonshot AI /chat/completions endpoint. """ - if not api_base: - api_base = "https://api.moonshot.ai/v1" + clean_api_base: Final = (api_base or "https://api.moonshot.ai/v1").rstrip("/") - if not api_base.endswith("/chat/completions"): - api_base = f"{api_base}/chat/completions" + if not clean_api_base.endswith("/chat/completions"): + return f"{clean_api_base}/chat/completions" - return api_base + return clean_api_base def get_supported_openai_params(self, model: str) -> list: """ diff --git a/litellm/llms/ollama/chat/transformation.py b/litellm/llms/ollama/chat/transformation.py index cb3080e6534..862db62d9df 100644 --- a/litellm/llms/ollama/chat/transformation.py +++ b/litellm/llms/ollama/chat/transformation.py @@ -218,14 +218,10 @@ class OllamaChatConfig(BaseConfig): Some providers need `model` in `api_base` """ - if api_base is None: - api_base = "http://localhost:11434" - if api_base.endswith("/api/chat"): - url = api_base - else: - url = f"{api_base}/api/chat" - - return url + clean_api_base: Final = (api_base or "http://localhost:11434").rstrip("/") + if clean_api_base.endswith("/api/chat"): + return clean_api_base + return f"{clean_api_base}/api/chat" def transform_request( self, diff --git a/litellm/llms/ollama/completion/transformation.py b/litellm/llms/ollama/completion/transformation.py index b1f69220de7..7e5b24f9db8 100644 --- a/litellm/llms/ollama/completion/transformation.py +++ b/litellm/llms/ollama/completion/transformation.py @@ -468,14 +468,10 @@ class OllamaConfig(BaseConfig): Some providers need `model` in `api_base` """ - if api_base is None: - api_base = "http://localhost:11434" - if api_base.endswith("/api/generate"): - url = api_base - else: - url = f"{api_base}/api/generate" - - return url + clean_api_base: Final = (api_base or "http://localhost:11434").rstrip("/") + if clean_api_base.endswith("/api/generate"): + return clean_api_base + return f"{clean_api_base}/api/generate" def get_model_response_iterator( self, diff --git a/litellm/llms/perplexity/embedding/transformation.py b/litellm/llms/perplexity/embedding/transformation.py index c93206db2bb..0e6de725c37 100644 --- a/litellm/llms/perplexity/embedding/transformation.py +++ b/litellm/llms/perplexity/embedding/transformation.py @@ -61,9 +61,10 @@ class PerplexityEmbeddingConfig(BaseEmbeddingConfig): stream: bool | None = None, ) -> str: if api_base: - if not api_base.endswith("/embeddings"): - api_base = f"{api_base}/v1/embeddings" - return api_base + clean_api_base: Final = api_base.rstrip("/") + if not clean_api_base.endswith("/embeddings"): + return f"{clean_api_base}/v1/embeddings" + return clean_api_base return "https://api.perplexity.ai/v1/embeddings" def get_supported_openai_params(self, model: str) -> list: diff --git a/litellm/llms/topaz/image_variations/transformation.py b/litellm/llms/topaz/image_variations/transformation.py index 94f60d29cb9..8d03b73e94e 100644 --- a/litellm/llms/topaz/image_variations/transformation.py +++ b/litellm/llms/topaz/image_variations/transformation.py @@ -39,8 +39,8 @@ class TopazImageVariationConfig(TopazModelInfo, BaseImageVariationConfig): litellm_params: dict, stream: bool | None = None, ) -> str: - api_base = api_base or "https://api.topazlabs.com" - return f"{api_base}/image/v1/enhance" + clean_api_base: Final = (api_base or "https://api.topazlabs.com").rstrip("/") + return f"{clean_api_base}/image/v1/enhance" def map_openai_params( self, diff --git a/litellm/llms/voyage/embedding/transformation.py b/litellm/llms/voyage/embedding/transformation.py index 7d74b1e00c4..8f52c1bb773 100644 --- a/litellm/llms/voyage/embedding/transformation.py +++ b/litellm/llms/voyage/embedding/transformation.py @@ -46,9 +46,10 @@ class VoyageEmbeddingConfig(BaseEmbeddingConfig): stream: bool | None = None, ) -> str: if api_base: - if not api_base.endswith("/embeddings"): - api_base = f"{api_base}/embeddings" - return api_base + clean_api_base: Final = api_base.rstrip("/") + if not clean_api_base.endswith("/embeddings"): + return f"{clean_api_base}/embeddings" + return clean_api_base return "https://api.voyageai.com/v1/embeddings" def get_supported_openai_params(self, model: str) -> list: diff --git a/litellm/llms/voyage/embedding/transformation_contextual.py b/litellm/llms/voyage/embedding/transformation_contextual.py index 870de8756bb..4c97301fe29 100644 --- a/litellm/llms/voyage/embedding/transformation_contextual.py +++ b/litellm/llms/voyage/embedding/transformation_contextual.py @@ -55,9 +55,10 @@ class VoyageContextualEmbeddingConfig(BaseEmbeddingConfig): stream: bool | None = None, ) -> str: if api_base: - if not api_base.endswith("/contextualizedembeddings"): - api_base = f"{api_base}/contextualizedembeddings" - return api_base + clean_api_base: Final = api_base.rstrip("/") + if not clean_api_base.endswith("/contextualizedembeddings"): + return f"{clean_api_base}/contextualizedembeddings" + return clean_api_base return "https://api.voyageai.com/v1/contextualizedembeddings" def get_supported_openai_params(self, model: str) -> list: # mutable-ok: base class signature diff --git a/litellm/llms/voyage/embedding/transformation_multimodal.py b/litellm/llms/voyage/embedding/transformation_multimodal.py index 814d5ab7eb0..ba5658e0568 100644 --- a/litellm/llms/voyage/embedding/transformation_multimodal.py +++ b/litellm/llms/voyage/embedding/transformation_multimodal.py @@ -55,9 +55,10 @@ class VoyageMultimodalEmbeddingConfig(BaseEmbeddingConfig): stream: bool | None = None, ) -> str: if api_base: - if not api_base.endswith("/multimodalembeddings"): - api_base = f"{api_base}/multimodalembeddings" - return api_base + clean_api_base: Final = api_base.rstrip("/") + if not clean_api_base.endswith("/multimodalembeddings"): + return f"{clean_api_base}/multimodalembeddings" + return clean_api_base return "https://api.voyageai.com/v1/multimodalembeddings" def get_supported_openai_params(self, model: str) -> list: diff --git a/tests/test_litellm/llms/test_provider_url_trailing_slash.py b/tests/test_litellm/llms/test_provider_url_trailing_slash.py new file mode 100644 index 00000000000..47fdb84db81 --- /dev/null +++ b/tests/test_litellm/llms/test_provider_url_trailing_slash.py @@ -0,0 +1,418 @@ +from litellm.llms.deepseek.chat.transformation import DeepSeekChatConfig +from litellm.llms.heroku.chat.transformation import HerokuChatConfig +from litellm.llms.jina_ai.embedding.transformation import JinaAIEmbeddingConfig +from litellm.llms.modelscope.chat.transformation import ModelScopeChatConfig +from litellm.llms.moonshot.chat.transformation import MoonshotChatConfig +from litellm.llms.ollama.chat.transformation import OllamaChatConfig +from litellm.llms.ollama.completion.transformation import OllamaConfig +from litellm.llms.perplexity.embedding.transformation import PerplexityEmbeddingConfig +from litellm.llms.topaz.image_variations.transformation import TopazImageVariationConfig +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 + + +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={}, + ) + url_with_endpoint = config.get_complete_url( + api_base="https://custom.deepseek.com/chat/completions/", + api_key=None, + model="deepseek-chat", + optional_params={}, + litellm_params={}, + ) + url_default = config.get_complete_url( + api_base=None, + 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 url_with_endpoint == "https://custom.deepseek.com/chat/completions" + assert url_default == "https://api.deepseek.com/beta/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={}, + ) + url_with_endpoint = chat_config.get_complete_url( + api_base="http://localhost:11434/api/chat/", + api_key=None, + model="llama3", + optional_params={}, + litellm_params={}, + ) + url_default = chat_config.get_complete_url( + api_base=None, + 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 url_with_endpoint == "http://localhost:11434/api/chat" + assert url_default == "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={}, + ) + comp_url_with_endpoint = completion_config.get_complete_url( + api_base="http://localhost:11434/api/generate/", + api_key=None, + model="llama3", + optional_params={}, + litellm_params={}, + ) + comp_url_default = completion_config.get_complete_url( + api_base=None, + 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 comp_url_with_endpoint == "http://localhost:11434/api/generate" + assert comp_url_default == "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={}, + ) + url_with_endpoint = config.get_complete_url( + api_base="https://api.moonshot.ai/v1/chat/completions/", + api_key=None, + model="moonshot-v1-8k", + optional_params={}, + litellm_params={}, + ) + url_default = config.get_complete_url( + api_base=None, + 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 url_with_endpoint == "https://api.moonshot.ai/v1/chat/completions" + assert url_default == "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={}, + ) + url_with_endpoint = config.get_complete_url( + api_base="https://api-inference.modelscope.cn/v1/chat/completions/", + api_key=None, + model="qwen", + optional_params={}, + litellm_params={}, + ) + url_default = config.get_complete_url( + api_base=None, + 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 url_with_endpoint == "https://api-inference.modelscope.cn/v1/chat/completions" + assert url_default == "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={}, + ) + url_with_endpoint = config.get_complete_url( + api_base="https://api.jina.ai/v1/embeddings/", + api_key=None, + model="jina-embeddings-v2", + optional_params={}, + litellm_params={}, + ) + url_default = config.get_complete_url( + api_base=None, + 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 url_with_endpoint == "https://api.jina.ai/v1/embeddings" + assert url_default == "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={}, + ) + url_with_endpoint = config.get_complete_url( + api_base="https://api.voyageai.com/v1/embeddings/", + api_key=None, + model="voyage-large-2", + optional_params={}, + litellm_params={}, + ) + url_default = config.get_complete_url( + api_base=None, + 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 url_with_endpoint == "https://api.voyageai.com/v1/embeddings" + assert url_default == "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={}, + ) + c_url_endpoint = contextual_config.get_complete_url( + api_base="https://api.voyageai.com/v1/contextualizedembeddings/", + api_key=None, + model="voyage-context-2", + optional_params={}, + litellm_params={}, + ) + c_url_default = contextual_config.get_complete_url( + api_base=None, + api_key=None, + model="voyage-context-2", + optional_params={}, + litellm_params={}, + ) + assert c_url == "https://api.voyageai.com/v1/contextualizedembeddings" + assert c_url_endpoint == "https://api.voyageai.com/v1/contextualizedembeddings" + assert c_url_default == "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={}, + ) + m_url_endpoint = multimodal_config.get_complete_url( + api_base="https://api.voyageai.com/v1/multimodalembeddings/", + api_key=None, + model="voyage-multimodal-3", + optional_params={}, + litellm_params={}, + ) + m_url_default = multimodal_config.get_complete_url( + api_base=None, + api_key=None, + model="voyage-multimodal-3", + optional_params={}, + litellm_params={}, + ) + assert m_url == "https://api.voyageai.com/v1/multimodalembeddings" + assert m_url_endpoint == "https://api.voyageai.com/v1/multimodalembeddings" + assert m_url_default == "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={}, + ) + url_with_endpoint = config.get_complete_url( + api_base="https://api.perplexity.ai/v1/embeddings/", + api_key=None, + model="sonar-medium", + optional_params={}, + litellm_params={}, + ) + url_default = config.get_complete_url( + api_base=None, + 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 url_with_endpoint == "https://api.perplexity.ai/v1/embeddings" + assert url_default == "https://api.perplexity.ai/v1/embeddings" + assert "//v1/embeddings" not in url_with_slash + + +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={}, + ) + url_with_endpoint = config.get_complete_url( + api_base="https://my-app.herokuapp.com/v1/chat/completions/", + 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 url_with_endpoint == "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={}, + ) + url_default = config.get_complete_url( + api_base=None, + 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 url_default == "https://api.topazlabs.com/image/v1/enhance" + assert "//image/v1/enhance" not in url_with_slash