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