Repository: Lightning-AI/LitServe

Issue:
override spec endpoint path if specified by the user 
here, I think it won't allow changing the URL when using `spec`, even if we pass it, as they have fixed urls.

_Originally posted by @bhimrazy in https://github.com/Lightning-AI/LitServe/pull/568#discussion_r2187083064_
            


Discussion:
@aniketmaurya, 
I was thinking about it and wanted to check a few things — for supporting a custom API path with `Spec`, do you think it’s better to pass the path directly as a parameter to the `Spec`, or we should reuse the one passed to `LitAPI`?

If we go with the latter, I think we might need to refactor how the endpoints are registered — moving it from `__init__` to a `presetup` event where we have access to the `LitAPI` instance.

Curious to hear your thoughts on this before proceeding — or if you had a different approach in mind.

hi @bhimrazy, if we allow users to provide the endpoint path in `LitAPI` for `Spec` then they won't have to learn anything new - we follow the idea of "don't make the user think". 

I would say, we wire it up so that api endpoint path can flow from LitAPI to the Spec. 



Patch:
diff --git a/src/litserve/constants.py b/src/litserve/constants.py
new file mode 100644
index 00000000..c8b30ae0
--- /dev/null
+++ b/src/litserve/constants.py
@@ -0,0 +1,14 @@
+# Copyright The Lightning AI team.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+_DEFAULT_LIT_API_PATH = "/predict"
diff --git a/src/litserve/specs/openai.py b/src/litserve/specs/openai.py
index 29121441..ef0cb7fb 100644
--- a/src/litserve/specs/openai.py
+++ b/src/litserve/specs/openai.py
@@ -26,6 +26,7 @@
 from fastapi.responses import StreamingResponse
 from pydantic import BaseModel, Field
 
+from litserve.constants import _DEFAULT_LIT_API_PATH
 from litserve.specs.base import LitSpec, _AsyncSpecWrapper
 from litserve.utils import LitAPIStatus, azip
 
@@ -352,10 +353,7 @@ def __init__(
         self,
     ):
         super().__init__()
-        # register the endpoint
         self.api_path = "/v1/chat/completions"  # default api path
-        self.add_endpoint("/v1/chat/completions", self.chat_completion, ["POST"])
-        self.add_endpoint("/v1/chat/completions", self.options_chat_completions, ["OPTIONS"])
 
     @property
     def stream(self):
@@ -364,6 +362,15 @@ def stream(self):
     def pre_setup(self, lit_api: "LitAPI"):
         from litserve import LitAPI
 
+        # Override the spec's api_path only if provided
+        if lit_api._api_path and lit_api._api_path != _DEFAULT_LIT_API_PATH:
+            self.api_path = lit_api._api_path
+
+        # register the endpoint
+        self.add_endpoint(self.api_path, self.chat_completion, ["POST"])
+        self.add_endpoint(self.api_path, self.options_chat_completions, ["OPTIONS"])
+
+        # validate LitAPI methods
         is_encode_response_original = lit_api.encode_response.__code__ is LitAPI.encode_response.__code__
 
         if lit_api.enable_async:
diff --git a/src/litserve/specs/openai_embedding.py b/src/litserve/specs/openai_embedding.py
index fbb15515..558bf55b 100644
--- a/src/litserve/specs/openai_embedding.py
+++ b/src/litserve/specs/openai_embedding.py
@@ -23,6 +23,7 @@
 from fastapi import status as status_code
 from pydantic import BaseModel
 
+from litserve.constants import _DEFAULT_LIT_API_PATH
 from litserve.specs.base import LitSpec
 from litserve.utils import LitAPIStatus
 
@@ -124,14 +125,20 @@ def predict(self, inputs) -> List[List[float]]:
 class OpenAIEmbeddingSpec(LitSpec):
     def __init__(self):
         super().__init__()
-        # register the endpoint
         self.api_path = "/v1/embeddings"  # default api path
-        self.add_endpoint("/v1/embeddings", self.embeddings_endpoint, ["POST"])
-        self.add_endpoint("/v1/embeddings", self.options_embeddings, ["GET"])
 
     def pre_setup(self, lit_api: "LitAPI"):
         from litserve import LitAPI
 
+        # Override the spec's api_path only if provided
+        if lit_api._api_path and lit_api._api_path != _DEFAULT_LIT_API_PATH:
+            self.api_path = lit_api._api_path
+
+        # register the endpoint
+        self.add_endpoint(self.api_path, self.embeddings_endpoint, ["POST"])
+        self.add_endpoint(self.api_path, self.options_embeddings, ["GET"])
+
+        # validate LitAPI methods
         if inspect.isgeneratorfunction(lit_api.predict):
             raise ValueError(
                 "You are using yield in your predict method, which is used for streaming.",


Test patch:
diff --git a/tests/unit/test_openai_embedding.py b/tests/unit/test_openai_embedding.py
index c4c16d67..004c2103 100644
--- a/tests/unit/test_openai_embedding.py
+++ b/tests/unit/test_openai_embedding.py
@@ -52,15 +52,16 @@ async def test_openai_embedding_spec_with_single_input(openai_embedding_request_
 
 
 @pytest.mark.asyncio
-async def test_openai_embedding_spec_with_multi_endpoint(openai_embedding_request_data):
+@pytest.mark.parametrize("api_path", ["/v1/embeddings", "/v2/embeddings"])
+async def test_openai_embedding_spec_with_custom_api_path(openai_embedding_request_data, api_path):
     server = ls.LitServer([
-        TestEmbedAPI(spec=OpenAIEmbeddingSpec()),
+        TestEmbedAPI(spec=OpenAIEmbeddingSpec(), api_path=api_path),
     ])
     with wrap_litserve_start(server) as server:
         async with LifespanManager(server.app) as manager, AsyncClient(
             transport=ASGITransport(app=manager.app), base_url="http://test"
         ) as ac:
-            resp = await ac.post("/v1/embeddings", json=openai_embedding_request_data, timeout=10)
+            resp = await ac.post(api_path, json=openai_embedding_request_data, timeout=10)
             assert resp.status_code == 200, "Status code should be 200"
             assert resp.json()["object"] == "list", "Object should be list"
             assert resp.json()["data"][0]["index"] == 0, "Index should be 0"
diff --git a/tests/unit/test_specs.py b/tests/unit/test_specs.py
index 3c3dea01..62389627 100644
--- a/tests/unit/test_specs.py
+++ b/tests/unit/test_specs.py
@@ -472,3 +472,16 @@ async def test_openai_spec_with_async_litapi(openai_request_data):
             assert resp.json()["choices"][0]["message"]["content"] == "This is a sample response", (
                 "LitAPI predict response should match with the generated output"
             )
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("api_path", ["/v1/chat/completions", "/v2/chat/completions"])
+async def test_openai_spec_with_custom_api_path(api_path, openai_request_data):
+    server = ls.LitServer(TestAPI(spec=OpenAISpec(), api_path=api_path))
+    with wrap_litserve_start(server) as server:
+        async with LifespanManager(server.app) as manager, AsyncClient(
+            transport=ASGITransport(app=manager.app), base_url="http://test"
+        ) as ac:
+            resp = await ac.post(api_path, json=openai_request_data, timeout=10)
+            assert resp.status_code == 200, "Status code should be 200"
+            assert resp.json()["choices"][0]["message"]["content"] == "This is a generated output"