From 548e042a05c5e1359abe1a5c6642e6fa0c249ccc Mon Sep 17 00:00:00 2001
From: touale <136764239@qq.com>
Date: Tue, 6 Jan 2026 16:32:39 +0800
Subject: [PATCH 1/5] feat: add duplicate route detection in APIIngress
---
src/framex/driver/ingress.py | 24 +++++++++++++++++++++++-
1 file changed, 23 insertions(+), 1 deletion(-)
diff --git a/src/framex/driver/ingress.py b/src/framex/driver/ingress.py
index 1739184..b1aef55 100644
--- a/src/framex/driver/ingress.py
+++ b/src/framex/driver/ingress.py
@@ -1,3 +1,4 @@
+import re
from collections.abc import Callable
from enum import Enum
from typing import Any
@@ -123,7 +124,7 @@ def _verify_api_key(request: Request, api_key: str | None = Depends(api_key_head
dependencies.append(Depends(_verify_api_key))
- app.add_api_route(
+ self.add_api_route(
path,
route_handler,
methods=methods,
@@ -146,3 +147,24 @@ async def inner(self) -> str: # pragma: no cover
def __repr__(self):
return BACKEND_NAME
+
+ def add_api_route(
+ self,
+ path: str,
+ endpoint: Callable[..., Any],
+ *,
+ methods: list[str] | None = None,
+ **kwargs: Any,
+ ) -> None:
+ method_set: set[str] = {m.upper() for m in (methods or [])}
+ norm_path = re.sub(r"\{[^}]+\}", "{}", path)
+
+ for route in app.routes:
+ if (
+ isinstance(route, APIRoute)
+ and re.sub(r"\{[^}]+\}", "{}", route.path) == norm_path
+ and route.methods & method_set
+ ):
+ raise RuntimeError(f"Duplicate API route: {sorted(method_set)} {norm_path}")
+
+ app.add_api_route(path, endpoint, methods=list(method_set), **kwargs)
From 73ee04de5ca71f77770944275b9a1f27dce0d059 Mon Sep 17 00:00:00 2001
From: touale <136764239@qq.com>
Date: Tue, 6 Jan 2026 16:41:26 +0800
Subject: [PATCH 2/5] test: add comprehensive APIIngress route validation tests
---
tests/driver/test_ingress.py | 111 +++++++++++++++++++++++++++++++++++
1 file changed, 111 insertions(+)
create mode 100644 tests/driver/test_ingress.py
diff --git a/tests/driver/test_ingress.py b/tests/driver/test_ingress.py
new file mode 100644
index 0000000..da61453
--- /dev/null
+++ b/tests/driver/test_ingress.py
@@ -0,0 +1,111 @@
+from unittest.mock import Mock, patch
+
+import pytest
+from fastapi.routing import APIRoute
+from starlette.routing import Route
+
+from framex.driver.ingress import APIIngress
+
+# ---------- helpers ----------
+
+
+def make_route(path: str, methods: set[str]) -> APIRoute:
+ route = Mock(spec=APIRoute)
+ route.path = path
+ route.methods = methods
+ return route
+
+
+# ---------- fixtures ----------
+
+
+@pytest.fixture
+def mock_app():
+ with patch("framex.driver.ingress.app") as app:
+ app.routes = []
+ app.add_api_route = Mock()
+ yield app
+
+
+@pytest.fixture
+def ingress():
+ return APIIngress.__new__(APIIngress)
+
+
+# ---------- tests ----------
+
+
+def test_add_first_route_success(ingress, mock_app):
+ endpoint = Mock()
+
+ ingress.add_api_route("/users", endpoint, methods=["GET"])
+
+ mock_app.add_api_route.assert_called_once_with("/users", endpoint, methods=["GET"])
+
+
+@pytest.mark.parametrize(
+ ("existing_path", "new_path"),
+ [
+ ("/users/{id}", "/users/{id}"),
+ ("/users/{id}", "/users/{user_id}"),
+ ("/users/{uid}/posts/{pid}", "/users/{id}/posts/{post_id}"),
+ ],
+)
+def test_duplicate_path_same_method_raises(ingress, mock_app, existing_path, new_path):
+ mock_app.routes = [make_route(existing_path, {"GET"})]
+
+ with pytest.raises(RuntimeError, match=r"Duplicate API route"):
+ ingress.add_api_route(new_path, Mock(), methods=["GET"])
+
+
+def test_same_path_different_method_allowed(ingress, mock_app):
+ mock_app.routes = [make_route("/users/{id}", {"GET"})]
+
+ ingress.add_api_route("/users/{id}", Mock(), methods=["POST"])
+
+ mock_app.add_api_route.assert_called_once()
+
+
+def test_overlapping_methods_raises(ingress, mock_app):
+ mock_app.routes = [make_route("/users", {"GET", "POST"})]
+
+ with pytest.raises(RuntimeError):
+ ingress.add_api_route("/users", Mock(), methods=["POST", "PUT"])
+
+
+def test_case_insensitive_methods(ingress, mock_app):
+ mock_app.routes = [make_route("/users", {"GET"})]
+
+ with pytest.raises(RuntimeError):
+ ingress.add_api_route("/users", Mock(), methods=["get"])
+
+
+def test_none_methods_becomes_empty_list(ingress, mock_app):
+ ingress.add_api_route("/users", Mock(), methods=None)
+
+ _, kwargs = mock_app.add_api_route.call_args
+ assert kwargs["methods"] == []
+
+
+def test_non_api_route_is_ignored(ingress, mock_app):
+ non_api_route = Mock(spec=Route)
+ non_api_route.path = "/users/{id}"
+ mock_app.routes = [non_api_route]
+
+ ingress.add_api_route("/users/{id}", Mock(), methods=["GET"])
+
+ mock_app.add_api_route.assert_called_once()
+
+
+def test_kwargs_are_passed_through(ingress, mock_app):
+ ingress.add_api_route(
+ "/users",
+ Mock(),
+ methods=["GET"],
+ tags=["users"],
+ response_class=Mock(),
+ )
+
+ _, kwargs = mock_app.add_api_route.call_args
+ assert kwargs["tags"] == ["users"]
+ assert "response_class" in kwargs
From 4875ab2cf02b767a315943c340cf7aeb26fd9df5 Mon Sep 17 00:00:00 2001
From: touale <136764239@qq.com>
Date: Tue, 6 Jan 2026 16:44:45 +0800
Subject: [PATCH 3/5] fix: default methods to GET when methods is None
---
src/framex/driver/ingress.py | 2 +-
tests/driver/test_ingress.py | 7 -------
2 files changed, 1 insertion(+), 8 deletions(-)
diff --git a/src/framex/driver/ingress.py b/src/framex/driver/ingress.py
index b1aef55..19a859d 100644
--- a/src/framex/driver/ingress.py
+++ b/src/framex/driver/ingress.py
@@ -156,7 +156,7 @@ def add_api_route(
methods: list[str] | None = None,
**kwargs: Any,
) -> None:
- method_set: set[str] = {m.upper() for m in (methods or [])}
+ method_set: set[str] = {m.upper() for m in methods} if methods else {"GET"}
norm_path = re.sub(r"\{[^}]+\}", "{}", path)
for route in app.routes:
diff --git a/tests/driver/test_ingress.py b/tests/driver/test_ingress.py
index da61453..fd1a5aa 100644
--- a/tests/driver/test_ingress.py
+++ b/tests/driver/test_ingress.py
@@ -80,13 +80,6 @@ def test_case_insensitive_methods(ingress, mock_app):
ingress.add_api_route("/users", Mock(), methods=["get"])
-def test_none_methods_becomes_empty_list(ingress, mock_app):
- ingress.add_api_route("/users", Mock(), methods=None)
-
- _, kwargs = mock_app.add_api_route.call_args
- assert kwargs["methods"] == []
-
-
def test_non_api_route_is_ignored(ingress, mock_app):
non_api_route = Mock(spec=Route)
non_api_route.path = "/users/{id}"
From 72268af391b8f2e042b8fb3e59879a846793b338 Mon Sep 17 00:00:00 2001
From: touale <136764239@qq.com>
Date: Tue, 6 Jan 2026 17:51:46 +0800
Subject: [PATCH 4/5] feat: add reversion config and enhance logging with
colored output
---
src/framex/__init__.py | 8 +++++++-
src/framex/config.py | 1 +
src/framex/driver/application.py | 3 ++-
src/framex/driver/ingress.py | 26 ++++++++++++++++++--------
src/framex/plugin/__init__.py | 4 ++--
src/framex/plugins/proxy/__init__.py | 11 ++++++-----
src/framex/utils.py | 4 ++++
tests/driver/test_auth.py | 4 ++--
8 files changed, 42 insertions(+), 19 deletions(-)
diff --git a/src/framex/__init__.py b/src/framex/__init__.py
index e3e3c23..b862d9a 100644
--- a/src/framex/__init__.py
+++ b/src/framex/__init__.py
@@ -77,7 +77,13 @@ def run(
builtin_plugins = settings.load_builtin_plugins if load_builtin_plugins is None else load_builtin_plugins
external_plugins = settings.load_plugins if load_plugins is None else load_plugins
- reversion = reversion or VERSION
+ if reversion:
+ settings.server.reversion = reversion
+ elif settings.server.reversion:
+ reversion = settings.server.reversion
+ else:
+ reversion = VERSION
+ settings.server.reversion = VERSION
if test_mode and use_ray:
raise RuntimeError("FlameX can not run when `test_mode` == True, and `use_ray` == True")
diff --git a/src/framex/config.py b/src/framex/config.py
index e453231..1309930 100644
--- a/src/framex/config.py
+++ b/src/framex/config.py
@@ -45,6 +45,7 @@ class ServerConfig(BaseModel):
num_cpus: int = -1
excluded_log_paths: list[str] = Field(default_factory=list)
ingress_config: dict[str, Any] = Field(default_factory=lambda: {"max_ongoing_requests": 60})
+ reversion: str = ""
class TestConfig(BaseModel):
diff --git a/src/framex/driver/application.py b/src/framex/driver/application.py
index 01448c3..3416c04 100644
--- a/src/framex/driver/application.py
+++ b/src/framex/driver/application.py
@@ -42,7 +42,8 @@ def build_openapi_description() -> str:
|--------|-------|
| Started At | `{started_at}` |
| Uptime | `{uptime}` |
-| Version | `v{VERSION}` |
+| Service-Version | `v{settings.server.reversion}` |
+| FrameX-Version | `v{VERSION}` |
---
"""
diff --git a/src/framex/driver/ingress.py b/src/framex/driver/ingress.py
index 19a859d..30ea016 100644
--- a/src/framex/driver/ingress.py
+++ b/src/framex/driver/ingress.py
@@ -17,7 +17,7 @@
from framex.driver.decorator import api_ingress
from framex.log import setup_logger
from framex.plugin.model import ApiType, PluginApi
-from framex.utils import escape_tag, safe_error_message
+from framex.utils import escape_tag, safe_error_message, shorten_str
app = create_fastapi_application()
@@ -75,13 +75,18 @@ def register_route(
from framex.config import settings
auth_keys = settings.auth.get_auth_keys(path)
- logger.debug(f"API({path}) with tags {tags} requires auth_keys {auth_keys}")
+ logger.trace(f"API({path}) with tags {tags} requires auth_keys {auth_keys}")
adapter = get_adapter()
try:
routes: list[str] = [route.path for route in app.routes if isinstance(route, Route | APIRoute)]
+ # logger.warning(f"API({path}) with tags {tags} is already registered, skipping duplicate registration.")
+ methods_str = ",".join(m.upper() for m in methods)
+
if path in routes:
- logger.warning(f"API({path}) with tags {tags} is already registered, skipping duplicate registration.")
+ logger.opt(colors=True).warning(
+ f"API route already registered: {methods_str:<4} {path[:40] + '...':<45} ({handle.deployment_name})"
+ )
return False
if (not path) or (not methods):
raise RuntimeError(f"Api({path}) or methods({methods}) is empty")
@@ -112,11 +117,13 @@ async def route_handler(response: Response, model: Model = Depends()) -> Any: #
# Inject auth dependency if needed
dependencies = []
if auth_keys is not None:
- logger.debug(f"API({path}) with tags {tags} requires auth.")
+ logger.trace(f"API({path}) with tags {tags} requires auth.")
def _verify_api_key(request: Request, api_key: str | None = Depends(api_key_header)) -> None:
if (api_key is None or api_key not in auth_keys) and (not auth_jwt(request)):
- logger.error(f"Unauthorized access attempt with API Key({api_key}) for API({path})")
+ logger.opt(colors=True).error(
+ f"Unauthorized access attempt with API Key({api_key}) for API({path})"
+ )
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=f"Invalid API Key({api_key}) for API({path})",
@@ -132,13 +139,16 @@ def _verify_api_key(request: Request, api_key: str | None = Depends(api_key_head
response_class=StreamingResponse if stream else JSONResponse,
dependencies=dependencies,
)
+ methods_str = ",".join(m.upper() for m in methods)
+ short_path = shorten_str(path)
logger.opt(colors=True).success(
- f"Succeeded to register api({methods}): {path} from {handle.deployment_name}, params: {params}"
+ f"API route registered: {methods_str:<4} {short_path:<45} ({handle.deployment_name})"
)
return True
except Exception as e:
- logger.opt(exception=e).error(f'Failed to register api "{escape_tag(path)}" from {handle.deployment_name}')
-
+ logger.opt(exception=e, colors=True).error(
+ f'Failed to register api "{escape_tag(path)}" from {handle.deployment_name}'
+ )
return False
@app.get("/ping")
diff --git a/src/framex/plugin/__init__.py b/src/framex/plugin/__init__.py
index 03e762e..3b98039 100644
--- a/src/framex/plugin/__init__.py
+++ b/src/framex/plugin/__init__.py
@@ -57,7 +57,7 @@ def init_all_deployments(enable_proxy: bool) -> list[DeploymentHandle]:
)
logger.opt(colors=True).warning(
f"Api({api_name}) not found, "
- f"plugin({dep.deployment}) will "
+ f"plugin({dep.deployment.__name__}) will "
f"use proxy plugin({PROXY_PLUGIN_NAME}) to transfer!"
)
else: # pragma: no cover
@@ -125,7 +125,7 @@ async def call_plugin_api(
res = result.get("data")
status = result.get("status")
if status not in settings.server.legal_proxy_code:
- logger.opt(colors=True).error(f"Proxy API {api_name} call illegal: {result}")
+ logger.opt(colors=True).error(f"<>Proxy API {api_name} call illegal: {result}")
raise RuntimeError(f"Proxy API {api_name} returned status {status}")
if res is None:
logger.opt(colors=True).warning(f"API {api_name} returned empty data")
diff --git a/src/framex/plugins/proxy/__init__.py b/src/framex/plugins/proxy/__init__.py
index b63b6f0..eb9e982 100644
--- a/src/framex/plugins/proxy/__init__.py
+++ b/src/framex/plugins/proxy/__init__.py
@@ -18,7 +18,7 @@
from framex.plugins.proxy.builder import create_pydantic_model, type_map
from framex.plugins.proxy.config import ProxyPluginConfig, settings
from framex.plugins.proxy.model import ProxyFunc, ProxyFuncHttpBody
-from framex.utils import cache_decode, cache_encode
+from framex.utils import cache_decode, cache_encode, shorten_str
__plugin_meta__ = PluginMetadata(
name="proxy",
@@ -85,13 +85,13 @@ async def _parse_openai_docs(self, url: str) -> None:
for path, details in paths.items():
# Check if the path is legal!
if not settings.is_white_url(path):
- logger.warning(f"Proxy api({path}) not in white_list, skipping...")
+ logger.opt(colors=True).warning(f"Proxy api({path}) not in white_list, skipping...")
continue
# Get auth api_keys
if auth_api_key := settings.auth.get_auth_keys(path):
headers = {"Authorization": auth_api_key[0]} # Use the first auth key set
- logger.debug(f"Proxy api({path}) requires auth")
+ logger.trace(f"Proxy api({path}) requires auth")
else:
headers = None
@@ -119,7 +119,7 @@ async def _parse_openai_docs(self, url: str) -> None:
Model = create_pydantic_model(schema_name, model_schema, components) # noqa
params.append(("model", Model))
- logger.opt(colors=True).debug(f"Found proxy api({method}) {url}{path}")
+ logger.opt(colors=True).trace(f"Found proxy api({method}) {url}{path}")
func_name = body.get("operationId")
is_stream = path in settings.force_stream_apis
func = self._create_dynamic_method(
@@ -234,7 +234,8 @@ def _create_dynamic_method(
# Construct dynamic methods
async def dynamic_method(**kwargs: Any) -> AsyncGenerator[str, None] | dict[str, Any] | str:
- logger.info(f"Calling proxy url: {url} with kwargs: {kwargs}")
+ log_info = shorten_str(str(kwargs), 512)
+ logger.info(f"Calling proxy url: {url} with kwargs: {log_info}")
validated = RequestModel(**kwargs) # Type Validation
query = {}
json_body = None
diff --git a/src/framex/utils.py b/src/framex/utils.py
index 8668345..84c9188 100644
--- a/src/framex/utils.py
+++ b/src/framex/utils.py
@@ -156,3 +156,7 @@ def safe_error_message(e: Exception) -> str:
if e.args:
return str(e.args[0])
return "Internal Server Error"
+
+
+def shorten_str(data: str, max_len: int = 45) -> str:
+ return data if len(data) <= max_len else data[: max_len - 3] + "..."
diff --git a/tests/driver/test_auth.py b/tests/driver/test_auth.py
index a26ea25..b1d918a 100644
--- a/tests/driver/test_auth.py
+++ b/tests/driver/test_auth.py
@@ -213,6 +213,6 @@ def test_docs_accessible_with_valid_jwt(self):
"secret",
algorithm="HS256",
)
-
- resp = client.get("/docs", cookies={"token": token}, follow_redirects=False)
+ client.cookies.set("token", token)
+ resp = client.get("/docs", follow_redirects=False)
assert resp.status_code == status.HTTP_200_OK
From 1b54e648e976fd5e8eb33b74b1b024a0eb0eb25f Mon Sep 17 00:00:00 2001
From: touale <136764239@qq.com>
Date: Tue, 6 Jan 2026 18:04:25 +0800
Subject: [PATCH 5/5] fix: correct plugin name display in warning log
---
src/framex/plugin/__init__.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/framex/plugin/__init__.py b/src/framex/plugin/__init__.py
index 3b98039..7ca3d29 100644
--- a/src/framex/plugin/__init__.py
+++ b/src/framex/plugin/__init__.py
@@ -57,7 +57,7 @@ def init_all_deployments(enable_proxy: bool) -> list[DeploymentHandle]:
)
logger.opt(colors=True).warning(
f"Api({api_name}) not found, "
- f"plugin({dep.deployment.__name__}) will "
+ f"plugin({dep.deployment}) will "
f"use proxy plugin({PROXY_PLUGIN_NAME}) to transfer!"
)
else: # pragma: no cover