From 2184a3dab1d78a073b28d81e3df6689df30c4047 Mon Sep 17 00:00:00 2001 From: Rohan Shetty Date: Sat, 18 Apr 2026 19:27:01 -0400 Subject: [PATCH] fix url construction for trailing v1 base urls --- .../llms/openai/chat/gpt_transformation.py | 15 +++--- .../llms/test_openai_get_models_url.py | 53 +++++++++++++++++++ 2 files changed, 62 insertions(+), 6 deletions(-) create mode 100644 tests/litellm/llms/test_openai_get_models_url.py diff --git a/litellm/llms/openai/chat/gpt_transformation.py b/litellm/llms/openai/chat/gpt_transformation.py index c12c6e6ba09..f7879de7ea3 100644 --- a/litellm/llms/openai/chat/gpt_transformation.py +++ b/litellm/llms/openai/chat/gpt_transformation.py @@ -721,14 +721,17 @@ class OpenAIGPTConfig(BaseLLMModelInfo, BaseConfig): if api_key is None: api_key = get_secret_str("OPENAI_API_KEY") - # Strip api_base to just the base URL (scheme + host + port) - parsed_url = httpx.URL(api_base) - base_url = f"{parsed_url.scheme}://{parsed_url.host}" - if parsed_url.port: - base_url += f":{parsed_url.port}" + # Remove trailing slash from api_base if present + api_base = api_base.rstrip("/") + + # If api_base already ends with /v1, just append /models + if api_base.endswith("/v1"): + url = f"{api_base}/models" + else: + url = f"{api_base}/v1/models" response = litellm.module_level_client.get( - url=f"{base_url}/v1/models", + url=url, headers={"Authorization": f"Bearer {api_key}"}, ) diff --git a/tests/litellm/llms/test_openai_get_models_url.py b/tests/litellm/llms/test_openai_get_models_url.py new file mode 100644 index 00000000000..0bce0e46fc2 --- /dev/null +++ b/tests/litellm/llms/test_openai_get_models_url.py @@ -0,0 +1,53 @@ +from unittest.mock import MagicMock, patch + +from litellm.llms.openai.chat.gpt_transformation import OpenAIGPTConfig + + +class TestGetModelsUrl: + """Test that get_models() constructs the correct /v1/models URL from api_base.""" + + def _get_url_for_api_base(self, api_base: str) -> str: + """Call get_models() with a mocked HTTP client and return the URL it used.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"data": []} + + with patch("litellm.module_level_client.get", return_value=mock_response) as mock_get: + OpenAIGPTConfig().get_models(api_key="fake-key", api_base=api_base) + return mock_get.call_args.kwargs["url"] + + def test_plain_base_url(self): + """Standard OpenAI base URL without path should get /v1/models appended.""" + assert self._get_url_for_api_base("https://api.openai.com") == "https://api.openai.com/v1/models" + + def test_trailing_slash(self): + """Trailing slash should be stripped before appending /v1/models.""" + assert self._get_url_for_api_base("https://api.openai.com/") == "https://api.openai.com/v1/models" + + def test_v1_already_present(self): + """api_base ending in /v1 should only get /models appended, not /v1/models.""" + assert self._get_url_for_api_base("https://api.openai.com/v1") == "https://api.openai.com/v1/models" + + def test_v1_with_trailing_slash(self): + """api_base ending in /v1/ should be normalized and get /models appended.""" + assert self._get_url_for_api_base("https://api.openai.com/v1/") == "https://api.openai.com/v1/models" + + def test_subpath_with_v1(self): + """api_base with a sub-path ending in /v1 should preserve the full path.""" + assert self._get_url_for_api_base("https://opencode.ai/zen/v1") == "https://opencode.ai/zen/v1/models" + + def test_subpath_with_v1_trailing_slash(self): + """Sub-path with /v1/ should be normalized and get /models appended.""" + assert self._get_url_for_api_base("https://opencode.ai/zen/v1/") == "https://opencode.ai/zen/v1/models" + + def test_subpath_without_v1(self): + """Sub-path without /v1 should get /v1/models appended.""" + assert self._get_url_for_api_base("https://opencode.ai/zen") == "https://opencode.ai/zen/v1/models" + + def test_localhost_with_port_and_v1(self): + """Localhost with port and /v1 should only get /models appended.""" + assert self._get_url_for_api_base("http://localhost:11434/v1") == "http://localhost:11434/v1/models" + + def test_localhost_without_v1(self): + """Localhost with port but no /v1 should get /v1/models appended.""" + assert self._get_url_for_api_base("http://localhost:11434") == "http://localhost:11434/v1/models"